diff --git a/client/components/PaneMCQ.jsx b/client/components/PaneMCQ.jsx new file mode 100644 index 0000000000000000000000000000000000000000..c77b1cc47063620eb9255b186a229aabfb052617 --- /dev/null +++ b/client/components/PaneMCQ.jsx @@ -0,0 +1,146 @@ +import React from 'react' + +/** + * PanelMCQ is a component that allows the user to generate mcq options + */ +class PanelMCQ extends React.Component { + constructor (props) { + super(props) + this.onChangeNPA = this.onChangeNPA.bind(this) + this.onChangeLabelType = this.onChangeLabelType.bind(this) + this.generateLabels = this.generateLabels.bind(this) + this.state = { + chosenLabelType: 0, + nrPossibleAnswers: 2, + labelTypes: ['None', 'True/False', 'A, B, C ...', '1, 2, 3 ...'] + } + } + + // this function is called when the input is changed for the number of possible answers + onChangeNPA (e) { + let value = parseInt(e.target.value) + if (!isNaN(value)) { + if (this.state.chosenLabelType === 1) { + value = 2 + } + this.setState({ + nrPossibleAnswers: value + }) + } + } + + // this function is called when the input is changed for the desired label type + onChangeLabelType (e) { + let value = parseInt(e.target.value) + if (!isNaN(value)) { + this.setState({ + chosenLabelType: value + }) + if (parseInt(value) === 1) { + this.setState({ + nrPossibleAnswers: 2 + }) + } + } + } + + /** + * This function generates an array with the labels for each option + * @param nrLabels the number of options that need to be generated + * @returns {any[]|string[]|number[]} + */ + generateLabels (nrLabels) { + let type = this.state.chosenLabelType + + switch (type) { + case 1: + return ['T', 'F'] + case 2: + return Array.from(Array(nrLabels).keys()).map((e) => String.fromCharCode(e + 65)) + case 3: + return Array.from(Array(nrLabels).keys()).map(e => e + 1) + default: + return Array(nrLabels).fill(' ') + } + } + + /** + * This function renders the panel with the inputs for generating multiple choice options + * @returns the react component containing the mcq panel + */ + render () { + return ( + <nav className='panel'> + <p className='panel-heading'> + Multiple Choice Question + </p> + <div className='panel-block'> + <div className='field'> + <React.Fragment> + <label className='label'>Number possible answers</label> + <div className='control'> + {(function () { + var optionList = [] + for (var i = 1; i <= this.props.totalNrAnswers; i++) { + const optionElement = <option key={i} value={String(i)}>{i}</option> + optionList.push(optionElement) + } + return (<div className='select is-hovered is-fullwidth'> + <select value={this.state.nrPossibleAnswers} onChange={this.onChangeNPA}>{optionList}</select> + </div>) + }.bind(this)())} + </div> + </React.Fragment> + </div> + </div> + <div className='panel-block'> + <div className='field'> + <React.Fragment> + <label className='label'>Answer boxes labels</label> + <div className='control'> + <div className='select is-hovered is-fullwidth'> + {(function () { + var optionList = this.state.labelTypes.map( + (label, i) => <option key={i} value={String(i)}>{label}</option> + ) + return ( + <div className='select is-hovered is-fullwidth'> + <select value={this.state.chosenLabelType} onChange={this.onChangeLabelType}> + {optionList} + </select> + </div> + ) + }.bind(this)())} + </div> + </div> + </React.Fragment> + </div> + </div> + <div className='panel-block field is-grouped'> + <button + disabled={this.props.disabledGenerateBoxes} + className='button is-link is-fullwidth' + onClick={() => { + let npa = this.state.nrPossibleAnswers + let labels = this.generateLabels(npa) + this.props.onGenerateBoxesClick(labels) + }} + > + Generate + </button> + <button + disabled={this.props.disabledDeleteBoxes} + className='button is-danger is-fullwidth' + onClick={() => { + this.props.onDeleteBoxesClick() + }} + > + Delete + </button> + </div> + </nav> + ) + } +} + +export default PanelMCQ diff --git a/client/components/answer_box.png b/client/components/answer_box.png new file mode 100644 index 0000000000000000000000000000000000000000..eafc3d30a9f99f21693f9075b9d446e43ce933fc Binary files /dev/null and b/client/components/answer_box.png differ diff --git a/client/views/Exam.css b/client/views/Exam.css index f126f590b4b1ecb24c75893ea13582ced0d185a4..e70724da849022d617945ccebe204ea143ad9fea 100644 --- a/client/views/Exam.css +++ b/client/views/Exam.css @@ -1,3 +1,33 @@ +:root { + --option-width:20px; + --label-font-size:14px; +} + +div.mcq-widget { + display:inline-flex; +} + +div.mcq-option { + display: block; + width: var(--option-width); + padding:2px; + box-sizing: content-box; + height: auto; +} + +div.mcq-option div.mcq-option-label { + display:block; + font-family: Arial, Helvetica, sans-serif; + font-size: var(--label-font-size); + text-align: center; +} + +div.mcq-option img.mcq-box { + display: block; + margin:auto; +} + + .editor-content { background-color: #ddd; border-radius: 10px diff --git a/client/views/Exam.jsx b/client/views/Exam.jsx index 60d0c33a5f54df9d4324b01e573898102bd32752..702b44a5cedddbbe008b53853981eac5d2531b50 100644 --- a/client/views/Exam.jsx +++ b/client/views/Exam.jsx @@ -6,6 +6,7 @@ import Hero from '../components/Hero.jsx' import './Exam.css' import GeneratedExamPreview from '../components/GeneratedExamPreview.jsx' import PanelGenerate from '../components/PanelGenerate.jsx' +import PanelMCQ from '../components/PaneMCQ.jsx' import ExamEditor from './ExamEditor.jsx' import update from 'immutability-helper' import ExamFinalizeMarkdown from './ExamFinalize.md' @@ -23,7 +24,9 @@ class Exams extends React.Component { widgets: [], previewing: false, deletingExam: false, - deletingWidget: false + deletingWidget: false, + deletingMCWidget: false, + showPanelMCQ: false } static getDerivedStateFromProps = (newProps, prevState) => { @@ -38,7 +41,9 @@ class Exams extends React.Component { id: problem.id, page: problem.page, name: problem.name, - graded: problem.graded + graded: problem.graded, + mc_options: problem.mc_options, + isMCQ: problem.mc_options && problem.mc_options.length !== 0 // is the problem a mc question - used to display PanelMCQ } } }) @@ -100,6 +105,19 @@ class Exams extends React.Component { })) } + createNewWidget = (widgetData) => { + this.setState((prevState) => { + return { + selectedWidgetId: widgetData.id, + widgets: update(prevState.widgets, { + [widgetData.id]: { + $set: widgetData + } + }) + } + }) + } + deleteWidget = (widgetId) => { const widget = this.state.widgets[widgetId] if (widget) { @@ -164,17 +182,10 @@ class Exams extends React.Component { selectedWidgetId: widgetId }) }} - createNewWidget={(widgetData) => { - this.setState((prevState) => { - return { - selectedWidgetId: widgetData.id, - widgets: update(prevState.widgets, { - [widgetData.id]: { - $set: widgetData - } - }) - } - }) + createNewWidget={this.createNewWidget} + updateMCWidgetPosition={this.updateMCWidgetPosition} + updateExam={() => { + this.props.updateExam(this.props.examID) }} /> ) @@ -234,6 +245,141 @@ class Exams extends React.Component { }) } + /** + * This function deletes the mc options coupled to a problem. + */ + deleteMCWidget = () => { + const widget = this.state.widgets[this.state.selectedWidgetId] + const options = widget.problem.mc_options + if (options.length > 0) { + options.forEach((option) => { + api.del('mult-choice/' + option.id) + .catch(err => { + console.log(err) + err.json().then(res => { + this.setState({ + deletingMCWidget: false + }) + Notification.error('Could not delete multiple choice option' + + (res.message ? ': ' + res.message : '')) + // update to try and get a consistent state + this.props.updateExam(this.props.examID) + }) + }) + }) + + // remove the mc options from the state + // note that his can happen before they are removed in the DB due to async calls + this.setState((prevState) => { + return { + widgets: update(prevState.widgets, { + [widget.id]: { + problem: { + mc_options: { + $set: [] + } + } + } + }), + deletingMCWidget: false + } + }) + } + } + + /** + * This method creates a widget object and adds it to the corresponding problem + * @param problemWidget The widget the mc option belongs to + * @param data the mc option + */ + createNewMCOWidget = (problemWidget, data) => { + this.setState((prevState) => { + return { + widgets: update(prevState.widgets, { + [this.state.selectedWidgetId]: { + problem: { + mc_options: { + $push: [data] + } + } + } + }) + } + }) + } + + /** + * This method is called when the mcq widget is moved. The positions of the options are stored separately and they + * all need to be updated + * @param widget the problem widget that includes the mcq widget + * @param data the new location of the mcq widget (the location of the top-left corner) + */ + updateMCWidgetPosition = (widget, data) => { + let newMCO = widget.problem.mc_options.map((option, i) => { + return { + 'widget': { + 'x': { + $set: data.x + i * 24 + }, + 'y': { + // each mc option needs to be positioned next to the previous option and should not overlap it + $set: data.y + } + } + } + }) + + // update the state with the new locations + this.setState(prevState => ({ + widgets: update(prevState.widgets, { + [widget.id]: { + 'problem': { + 'mc_options': newMCO + } + } + }) + })) + } + + /** + * This method generates MC options by making the right calls to the api and creating + * the widget object in the mc_options array of the corresponding problem. + * @param problemWidget the problem widget the mc options belong to + * @param labels the labels for the options + * @param index the index in the labels array (the function is recusive, this index is increased) + * @param xPos x position of the current option + * @param yPos y position of the current option + */ + generateAnswerBoxes = (problemWidget, labels, index, xPos, yPos) => { + if (labels.length === index) return + + let data = { + 'label': labels[index], + 'problem_id': problemWidget.problem.id, + 'feedback_id': null, + 'widget': { + 'name': 'mc_option_' + labels[index], + 'x': xPos, + 'y': yPos, + 'type': 'mcq_widget' + } + } + + const formData = new window.FormData() + formData.append('name', data.widget.name) + formData.append('x', data.widget.x) + formData.append('y', data.widget.y) + formData.append('problem_id', data.problem_id) + formData.append('label', data.label) + api.put('mult-choice/', formData).then(result => { + data.id = result.mult_choice_id + this.createNewMCOWidget(problemWidget, data) + this.generateAnswerBoxes(problemWidget, labels, index + 1, xPos + 24, yPos) + }).catch(err => { + console.log(err) + }) + } + SidePanel = (props) => { const selectedWidgetId = this.state.selectedWidgetId let selectedWidget = selectedWidgetId && this.state.widgets[selectedWidgetId] @@ -241,11 +387,16 @@ class Exams extends React.Component { let widgetEditDisabled = this.state.previewing || !problem let isGraded = problem && problem.graded let widgetDeleteDisabled = widgetEditDisabled || isGraded + let totalNrAnswers = 12 // the upper limit for the nr of possible answer boxes + let containsMCOptions = (problem && problem.mc_options.length > 0) || false + let disabledDeleteBoxes = !containsMCOptions + let isMCQ = (problem && problem.isMCQ) || false return ( <React.Fragment> <this.PanelEdit disabledEdit={widgetEditDisabled} + disableIsMCQ={widgetEditDisabled || containsMCOptions} disabledDelete={widgetDeleteDisabled} onDeleteClick={() => { this.setState({deletingWidget: true}) @@ -266,7 +417,42 @@ class Exams extends React.Component { })) }} saveProblemName={this.saveProblemName} + isMCQProblem={isMCQ} + onMCQChange={ + (checked) => { + this.setState(prevState => ({ + changedWidgetId: selectedWidgetId, + widgets: update(prevState.widgets, { + [selectedWidgetId]: { + problem: { + isMCQ: { + $set: checked + } + } + } + }) + })) + } + } /> + { isMCQ ? ( + <PanelMCQ + totalNrAnswers={totalNrAnswers} + disabledGenerateBoxes={containsMCOptions} + disabledDeleteBoxes={disabledDeleteBoxes} + problem={problem} + onGenerateBoxesClick={(labels) => { + let problemWidget = this.state.widgets[this.state.selectedWidgetId] + // position the new mc option widget inside the problem widget + let xPos = problemWidget.x + 2 + let yPos = problemWidget.y + 2 + this.generateAnswerBoxes(problemWidget, labels, 0, xPos, yPos) + }} + onDeleteBoxesClick={() => { + this.setState({deletingMCWidget: true}) + }} + /> + ) : null } <this.PanelExamActions /> </React.Fragment> ) @@ -280,14 +466,18 @@ class Exams extends React.Component { <p className='panel-heading'> Problem details </p> - <div className='panel-block'> - <div className='field'> - {selectedWidgetId === null ? ( - <p style={{margin: '0.625em 0', minHeight: '3em'}}> + {selectedWidgetId === null ? ( + <div className='panel-block'> + <div className='field'> + <p style={{ margin: '0.625em 0', minHeight: '3em' }}> To create a problem, draw a rectangle on the exam. </p> - ) : ( - <React.Fragment> + </div> + </div> + ) : ( + <React.Fragment> + <div className='panel-block'> + <div className='field'> <label className='label'>Name</label> <div className='control'> <input @@ -300,12 +490,24 @@ class Exams extends React.Component { }} onBlur={(e) => { props.saveProblemName(e.target.value) - }} /> + }} + /> </div> - </React.Fragment> - )} - </div> - </div> + </div> + </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> + </div> + </div> + </React.Fragment> + )} <div className='panel-block'> <button disabled={props.disabledDelete} @@ -315,7 +517,6 @@ class Exams extends React.Component { Delete problem </button> </div> - </nav> ) } @@ -451,6 +652,18 @@ class Exams extends React.Component { onCancel={() => this.setState({deletingWidget: false})} onConfirm={() => this.deleteWidget(this.state.selectedWidgetId)} /> + <ConfirmationModal + active={this.state.deletingMCWidget && this.state.selectedWidgetId != null} + color='is-danger' + headerText={`Are you sure you want to delete the multiple choice options for problem "${ + this.state.selectedWidgetId && + this.state.widgets[this.state.selectedWidgetId] && + this.state.widgets[this.state.selectedWidgetId].problem && + this.state.widgets[this.state.selectedWidgetId].problem.name}"`} + confirmText='Delete multiple choice options' + onCancel={() => this.setState({deletingMCWidget: false})} + onConfirm={() => this.deleteMCWidget(this.state.selectedWidgetId)} + /> </div> } } diff --git a/client/views/ExamEditor.jsx b/client/views/ExamEditor.jsx index 89cf1d8d163c9ace55510395de54147c9d694b96..15781e4c19b4aa826c3fb22667c62393a41cfeb9 100644 --- a/client/views/ExamEditor.jsx +++ b/client/views/ExamEditor.jsx @@ -8,6 +8,7 @@ import studentIdExampleImage from '../components/student_id_example.png' // FIXME! // eslint-disable-next-line import/no-webpack-loader-syntax import studentIdExampleImageSize from '!image-dimensions-loader!../components/student_id_example.png' +import answerBoxImage from '../components/answer_box.png' import EmptyPDF from '../components/EmptyPDF.jsx' import PDFOverlay from '../components/PDFOverlay.jsx' @@ -86,13 +87,16 @@ class ExamEditor extends React.Component { if (selectionBox.width >= this.props.problemMinWidth && selectionBox.height >= this.props.problemMinHeight) { const problemData = { name: 'New problem', // TODO: Name - page: this.props.page + page: this.props.page, + mc_options: [], + isMCQ: false } const widgetData = { x: Math.round(selectionBox.left), y: Math.round(selectionBox.top), width: Math.round(selectionBox.width), - height: Math.round(selectionBox.height) + height: Math.round(selectionBox.height), + type: 'problem_widget' } const formData = new window.FormData() formData.append('exam_id', this.props.examID) @@ -169,131 +173,281 @@ class ExamEditor extends React.Component { } } + /** + * This method is called when the position of a widget has changed. It informs the server about the relocation. + * @param widget the widget that was relocated + * @param data the new location + */ + updateWidgetPositionDB = (widget, data) => { + api.patch('widgets/' + widget.id, data).then(() => { + // ok + }).catch(err => { + console.log(err) + // update to try and get a consistent state + this.props.updateExam() + }) + } + /** + * This function renders a group of options into one draggable widget + * @returns {*} + */ + renderMCWidget = (widget) => { + let width = 24 * widget.problem.mc_options.length + let height = 38 + let enableResizing = false + const isSelected = widget.id === this.props.selectedWidgetId + return ( + <ResizeAndDrag + key={'widget_mc_' + widget.id} + bounds={'[data-key="widget_' + widget.id + '"]'} + minWidth={width} + minHeight={height} + enableResizing={{ + bottom: enableResizing, + bottomLeft: enableResizing, + bottomRight: enableResizing, + left: enableResizing, + right: enableResizing, + top: enableResizing, + topLeft: enableResizing, + topRight: enableResizing + }} + position={{ + x: widget.problem.mc_options[0].widget.x, + y: widget.problem.mc_options[0].widget.y + }} + size={{ + width: width, + height: height + }} + onDragStart={() => { + this.props.selectWidget(widget.id) + }} + onDragStop={(e, data) => { + this.props.updateMCWidgetPosition(widget, { + x: Math.round(data.x), + y: Math.round(data.y) + }) + + widget.problem.mc_options.forEach( + (option, i) => { + let newData = { + x: Math.round(data.x), + y: Math.round(data.y) + i * 24 + } + this.updateWidgetPositionDB(option, newData) + }) + }} + > + <div className={isSelected ? 'mcq-widget widget selected' : 'mcq-widget widget '}> + {widget.problem.mc_options.map((option) => { + return ( + <div key={'widget_mco_' + option.id} className='mcq-option'> + <div className='mcq-option-label'> + {option.label} + </div> + <img className='mcq-box' src={answerBoxImage} /> + </div> + ) + })} + </div> + </ResizeAndDrag> + ) + } + + /** + * Render problem widget and the mc options that correspond to the problem + * @param widget the corresponding widget object from the db + * @returns {Array} + */ + renderProblemWidget = (widget) => { + // Only render when numPage is set + if (widget.problem.page !== this.props.page) return [] + + let enableResizing = true + const isSelected = widget.id === this.props.selectedWidgetId + let minWidth = this.props.problemMinWidth + let minHeight = this.props.problemMinHeight + let elementList = [( + <ResizeAndDrag + key={'widget_' + widget.id} + data-key={'widget_' + widget.id} + bounds='parent' + minWidth={minWidth} + minHeight={minHeight} + enableResizing={{ + bottom: enableResizing, + bottomLeft: enableResizing, + bottomRight: enableResizing, + left: enableResizing, + right: enableResizing, + top: enableResizing, + topLeft: enableResizing, + topRight: enableResizing + }} + position={{ + x: widget.x, + y: widget.y + }} + size={{ + width: widget.width, + height: widget.height + }} + onResize={(e, direction, ref, delta, position) => { + this.props.updateWidget(widget.id, { + width: { $set: ref.offsetWidth }, + height: { $set: ref.offsetHeight }, + x: { $set: Math.round(position.x) }, + y: { $set: Math.round(position.y) } + }) + }} + onResizeStop={(e, direction, ref, delta, position) => { + api.patch('widgets/' + widget.id, { + x: Math.round(position.x), + y: Math.round(position.y), + width: ref.offsetWidth, + height: ref.offsetHeight + }).then(() => { + // ok + }).catch(err => { + console.log(err) + // update to try and get a consistent state + this.props.updateExam() + }) + }} + onDragStart={() => { + this.props.selectWidget(widget.id) + }} + onDragStop={(e, data) => { + this.props.updateWidget(widget.id, { + x: { $set: Math.round(data.x) }, + y: { $set: Math.round(data.y) } + }) + api.patch('widgets/' + widget.id, { + x: Math.round(data.x), + y: Math.round(data.y) + }).then(() => { + // ok + }).catch(err => { + console.log(err) + // update to try and get a consistent state + this.props.updateExam() + }) + }} + > + <div + className={isSelected ? 'widget selected' : 'widget'} + /> + </ResizeAndDrag> + )] + + // depending on the rendering option, render the mc_options separately or in a single widget + if (widget.problem.mc_options.length > 0 && !this.props.finalized) { + elementList.push(this.renderMCWidget(widget)) + } + + return elementList + } + + /** + * Render exam widgets. + * @param widget the corresponding widget object from the db + * @returns {Array} + */ + renderExamWidget = (widget) => { + if (this.props.finalized) return [] + + let minWidth, minHeight + let enableResizing = false + const isSelected = widget.id === this.props.selectedWidgetId + let image + if (widget.name === 'barcode_widget') { + minWidth = barcodeExampleImageSize.width + minHeight = barcodeExampleImageSize.height + image = barcodeExampleImage + } else if (this.props.page === 0 && widget.name === 'student_id_widget') { + minWidth = studentIdExampleImageSize.width + minHeight = studentIdExampleImageSize.height + image = studentIdExampleImage + } else { + return [] + } + + return [( + <ResizeAndDrag + key={'widget_' + widget.id} + bounds='parent' + minWidth={minWidth} + minHeight={minHeight} + enableResizing={{ + bottom: enableResizing, + bottomLeft: enableResizing, + bottomRight: enableResizing, + left: enableResizing, + right: enableResizing, + top: enableResizing, + topLeft: enableResizing, + topRight: enableResizing + }} + position={{ + x: widget.x, + y: widget.y + }} + size={{ + width: widget.width, + height: widget.height + }} + onDragStart={() => { + this.props.selectWidget(widget.id) + }} + onDragStop={(e, data) => { + this.props.updateWidget(widget.id, { + x: { $set: Math.round(data.x) }, + y: { $set: Math.round(data.y) } + }) + api.patch('widgets/' + widget.id, { + x: Math.round(data.x), + y: Math.round(data.y) + }).then(() => { + // ok + }).catch(err => { + console.log(err) + // update to try and get a consistent state + this.props.updateExam() + }) + }} + > + <div + className={isSelected ? 'widget selected' : 'widget'} + style={{ + boxSizing: 'content-box', + backgroundImage: 'url(' + image + ')', + backgroundRepeat: 'no-repeat' + }} + /> + </ResizeAndDrag> + )] + } + + /** + * Render all the widgets by calling the right rendering function for each widget type + * @returns {Array} + */ renderWidgets = () => { // Only render when numPage is set if (this.props.numPages !== null && this.props.widgets) { - const widgets = this.props.widgets.filter(widget => { - if (widget.name === 'student_id_widget' || - widget.name === 'barcode_widget') { - return !this.props.finalized - } else if (widget.problem) { - return widget.problem.page === this.props.page - } else { - return true - } - }) + let widgets = this.props.widgets + let elementList = [] - let minWidth - let minHeight - let view - let enableResizing - return widgets.map((widget) => { - const isSelected = widget.id === this.props.selectedWidgetId - - if (widget.problem) { - minWidth = this.props.problemMinWidth - minHeight = this.props.problemMinHeight - view = ( - <div - className={isSelected ? 'widget selected' : 'widget'} - /> - ) - enableResizing = true - } else { - let image - if (widget.name === 'barcode_widget') { - minWidth = barcodeExampleImageSize.width - minHeight = barcodeExampleImageSize.height - image = barcodeExampleImage - } else if (this.props.page === 0 && widget.name === 'student_id_widget') { - minWidth = studentIdExampleImageSize.width - minHeight = studentIdExampleImageSize.height - image = studentIdExampleImage - } else { - return null - } - view = ( - <div - className={isSelected ? 'widget selected' : 'widget'} - style={{ - boxSizing: 'content-box', - backgroundImage: 'url(' + image + ')', - backgroundRepeat: 'no-repeat' - }} - /> - ) - enableResizing = false + widgets.forEach((widget) => { + if (widget.type === 'exam_widget') { + elementList = elementList.concat(this.renderExamWidget(widget)) + } else if (widget.type === 'problem_widget') { + elementList = elementList.concat(this.renderProblemWidget(widget)) } - return ( - <ResizeAndDrag - key={'widget_' + widget.id} - bounds='parent' - minWidth={minWidth} - minHeight={minHeight} - enableResizing={{ - bottom: enableResizing, - bottomLeft: enableResizing, - bottomRight: enableResizing, - left: enableResizing, - right: enableResizing, - top: enableResizing, - topLeft: enableResizing, - topRight: enableResizing - }} - position={{ - x: widget.x, - y: widget.y - }} - size={{ - width: widget.width, - height: widget.height - }} - onResize={(e, direction, ref, delta, position) => { - this.props.updateWidget(widget.id, { - width: { $set: ref.offsetWidth }, - height: { $set: ref.offsetHeight }, - x: { $set: Math.round(position.x) }, - y: { $set: Math.round(position.y) } - }) - }} - onResizeStop={(e, direction, ref, delta, position) => { - api.patch('widgets/' + widget.id, { - x: Math.round(position.x), - y: Math.round(position.y), - width: ref.offsetWidth, - height: ref.offsetHeight - }).then(() => { - // ok - }).catch(err => { - console.log(err) - // update to try and get a consistent state - this.updateExam() - }) - }} - onDragStart={() => { - this.props.selectWidget(widget.id) - }} - onDragStop={(e, data) => { - this.props.updateWidget(widget.id, { - x: { $set: Math.round(data.x) }, - y: { $set: Math.round(data.y) } - }) - api.patch('widgets/' + widget.id, { - x: Math.round(data.x), - y: Math.round(data.y) - }).then(() => { - // ok - }).catch(err => { - console.log(err) - // update to try and get a consistent state - this.updateExam() - }) - }} - > - {view} - </ResizeAndDrag> - ) }) + + return elementList } } diff --git a/data/course.sqlite b/data/course.sqlite index 234038aee11424456894a18d144754cc951e9843..42369d0718bc1a16a99c196ae7dcd845a0a3ab30 100644 Binary files a/data/course.sqlite and b/data/course.sqlite differ diff --git a/zesje/api/exams.py b/zesje/api/exams.py index 554b3591642ff4a87dfbba2aa650ce951476c45b..5234178e7d35ef07c2c02dda45641521cbc1655a 100644 --- a/zesje/api/exams.py +++ b/zesje/api/exams.py @@ -181,18 +181,20 @@ class Exams(Resource): 'y': prob.widget.y, 'width': prob.widget.width, 'height': prob.widget.height, + 'type': prob.widget.type }, 'graded': any([sol.graded_by is not None for sol in prob.solutions]), 'mc_options': [ { - 'id': mc_option.id, - 'label': mc_option.label, - 'feedback_id': mc_option.feedback_id, - 'widget': { - 'name': mc_option.name, - 'x': mc_option.x, - 'y': mc_option.y - } + 'id': mc_option.id, + 'label': mc_option.label, + 'feedback_id': mc_option.feedback_id, + 'widget': { + 'name': mc_option.name, + 'x': mc_option.x, + 'y': mc_option.y, + 'type': mc_option.type + } } for mc_option in prob.mc_options ] } for prob in exam.problems # Sorted by prob.id @@ -203,6 +205,7 @@ class Exams(Resource): 'name': widget.name, 'x': widget.x, 'y': widget.y, + 'type': widget.type } for widget in exam.widgets # Sorted by widget.id ], 'finalized': exam.finalized,