Merge pull request #253 from Ohtuilmo/feature/separate-inputs-for-each-student-in-peer-review

Separate inputs for each team member in peer review
This commit is contained in:
Mikko Ahro
2024-04-22 10:48:34 +03:00
committed by GitHub
10 changed files with 354 additions and 54 deletions
+47 -19
View File
@@ -4,7 +4,9 @@ const { checkLogin, checkAdmin } = require('../middleware')
const handleDatabaseError = (res, error) => {
console.log(error)
res.status(500).json({ error: 'Something is wrong... try reloading the page' })
res
.status(500)
.json({ error: 'Something is wrong... try reloading the page' })
}
const isNil = (value) => value === undefined || value === null
@@ -31,6 +33,11 @@ const validateAnswerSheet = (peerReview) => {
if (error) {
return error
}
} else if (question.type === 'peerReview') {
error = validatePeerReviewAnswer(question)
if (error) {
return error
}
} else if (question.type === 'radio') {
error = validateRadioAnswer(question)
if (error) {
@@ -48,6 +55,23 @@ const validateAnswerSheet = (peerReview) => {
return null
}
const validatePeerReviewAnswer = (question) => {
const peerAnswers = Object.values(question.peers)
for (let answer of peerAnswers) {
if (answer.length === 0) {
return 'You must answer all questions'
}
if (answer.length < 7) {
return 'Text answers must be over 7 characters long.'
}
if (answer.length > 2000) {
return 'Text answer must be less than 2000 characters.'
}
}
return null
}
const validateTextAnswer = (question) => {
if (question.answer.length === 0) {
return 'You must answer all questions'
@@ -126,7 +150,9 @@ const createTestData = async (req, res) => {
let returnLog = []
for (let peerReview in peerReviews) {
try {
const sentAnswerSheet = await db.PeerReview.create(peerReviews[peerReview])
const sentAnswerSheet = await db.PeerReview.create(
peerReviews[peerReview]
)
returnLog = returnLog.concat(sentAnswerSheet)
} catch (err) {
return handleDatabaseError(res, err)
@@ -138,14 +164,14 @@ const createTestData = async (req, res) => {
peerReviewRouter.get('/', checkLogin, async (req, res) => {
try {
const registrationManagement = await db.RegistrationManagement.findOne({
order: [['createdAt', 'DESC']]
order: [['createdAt', 'DESC']],
})
const entries = await db.PeerReview.findAll({
where: {
user_id: req.user.id,
configuration_id: registrationManagement.peer_review_conf
}
configuration_id: registrationManagement.peer_review_conf,
},
})
return res.status(200).json(entries)
@@ -157,7 +183,7 @@ peerReviewRouter.get('/', checkLogin, async (req, res) => {
peerReviewRouter.get('/all', checkAdmin, async (req, res) => {
try {
const reviews = await db.PeerReview.findAll({
include: ['user']
include: ['user'],
})
return res.status(200).json(reviews)
} catch (error) {
@@ -167,9 +193,11 @@ peerReviewRouter.get('/all', checkAdmin, async (req, res) => {
peerReviewRouter.get('/forInstructor', checkLogin, async (req, res) => {
try {
const query = req.user.admin ? {} : {
where: { instructorId: req.user.id }
}
const query = req.user.admin
? {}
: {
where: { instructorId: req.user.id },
}
const instructedGroups = await db.Group.findAll(query)
if (instructedGroups.length === 0) {
@@ -187,21 +215,21 @@ peerReviewRouter.get('/forInstructor', checkLogin, async (req, res) => {
as: 'students',
model: db.User,
attributes: ['student_number', 'first_names', 'last_name'],
through: { attributes: [] }
through: { attributes: [] },
},
{
as: 'configuration',
model: db.Configuration
model: db.Configuration,
},
{
as: 'instructor',
model: db.User
}
]
model: db.User,
},
],
})
const allAnswers = await db.PeerReview.findAll({
include: ['user']
include: ['user'],
})
const filterAndFormatAnswers = (answers, group, round) => {
@@ -218,9 +246,9 @@ peerReviewRouter.get('/forInstructor', checkLogin, async (req, res) => {
return {
student: {
first_names: answer.user.first_names,
last_name: answer.user.last_name
last_name: answer.user.last_name,
},
answer_sheet: answer.answer_sheet
answer_sheet: answer.answer_sheet,
}
})
}
@@ -241,10 +269,10 @@ peerReviewRouter.get('/forInstructor', checkLogin, async (req, res) => {
configurationName: group.configuration.name,
instructorName: group.instructor
? group.instructor.first_names + ' ' + group.instructor.last_name
: ''
: '',
},
round1Answers,
round2Answers
round2Answers,
})
return list
@@ -60,6 +60,15 @@ const initTests = () => {
},
questionHeader: 'Miten perustelet tämän arvosanan?',
},
{
id: 5,
type: 'peerReview',
peers: {
'Donald John Trump': 'Lorem ipsum dolor sit amet',
'Timo *Teppo Tellervo Testaaja': '...consectetur adipiscing elit. Fusce at.',
},
questionHeader: 'Arviot erikseen ryhmän jäsenistä',
},
],
},
{
@@ -95,6 +104,15 @@ const initTests = () => {
},
questionHeader: 'Miten perustelet tämän arvosanan?',
},
{
id: 5,
type: 'peerReview',
peers: {
'Donald John Trump': 'Lorem ipsum dolor sit amet',
'Timo *Teppo Tellervo Testaaja': '...consectetur adipiscing elit. Fusce at.',
},
questionHeader: 'Arviot erikseen ryhmän jäsenistä',
},
],
},
],
@@ -135,6 +153,15 @@ const initTests = () => {
},
questionHeader: 'Miten perustelet tämän arvosanan?',
},
{
id: 5,
type: 'peerReview',
peers: {
'Donald John Trump': 'Lorem ipsum dolor sit amet',
'Timo *Teppo Tellervo Testaaja': '...consectetur adipiscing elit. Fusce at.',
},
questionHeader: 'Arviot erikseen ryhmän jäsenistä',
},
],
},
{
@@ -170,6 +197,15 @@ const initTests = () => {
},
questionHeader: 'Miten perustelet tämän arvosanan?',
},
{
id: 5,
type: 'peerReview',
peers: {
'Donald John Trump': 'Lorem ipsum dolor sit amet',
'Timo *Teppo Tellervo Testaaja': '...consectetur adipiscing elit. Fusce at.',
},
questionHeader: 'Arviot erikseen ryhmän jäsenistä',
},
],
},
],
@@ -210,6 +246,15 @@ const initTests = () => {
},
questionHeader: 'Miten perustelet tämän arvosanan?',
},
{
id: 5,
type: 'peerReview',
peers: {
'Donald John Trump': 'Lorem ipsum dolor sit amet',
'Timo *Teppo Tellervo Testaaja': '...consectetur adipiscing elit. Fusce at.',
},
questionHeader: 'Arviot erikseen ryhmän jäsenistä',
},
],
},
{
@@ -245,6 +290,15 @@ const initTests = () => {
},
questionHeader: 'Miten perustelet tämän arvosanan?',
},
{
id: 5,
type: 'peerReview',
peers: {
'Donald John Trump': 'Lorem ipsum dolor sit amet',
'Timo *Teppo Tellervo Testaaja': '...consectetur adipiscing elit. Fusce at.',
},
questionHeader: 'Arviot erikseen ryhmän jäsenistä',
},
],
},
],
@@ -282,7 +336,7 @@ describe('Instructor page', () => {
cy.get('.configuration-menu-item').contains('Konfiguraatio 2').click()
cy.contains('Kämmäilijät')
cy.contains(
"This group hasn't answered to the second peer review round yet."
"This group has not answered to the second peer review round yet."
)
cy.get('[data-cy=configuration-selector]').click()
cy.get('.configuration-menu-item').contains('Konfiguraatio 1').click()
@@ -20,6 +20,11 @@ describe('Review-questions page', () => {
description: '',
type: 'number',
},
{
header: 'Peer review for group members',
description: 'Separate review for each member of the group',
type: 'peerReview',
},
{
header: 'Ok and with option',
description: 'Choose a radio button you want',
@@ -68,7 +68,7 @@ const Answers = ({ answers, currentConfiguration, currentGroupID, viewMode }) =>
</div>
) : (
<h2>
This group hasn't answered to the first peer review round yet.
This group has not answered to the first peer review round yet.
</h2>
)}
@@ -86,7 +86,7 @@ const Answers = ({ answers, currentConfiguration, currentGroupID, viewMode }) =>
</div>
) : (
<h2>
This group hasn't answered to the second peer review round yet.
This group has not answered to the second peer review round yet.
</h2>
)}
@@ -94,7 +94,7 @@ const Answers = ({ answers, currentConfiguration, currentGroupID, viewMode }) =>
</div>
)}
else {
return (<div></div>)
return (<div key='empty-div'></div>)
}
})}
</div>
@@ -129,6 +129,12 @@ const GroupAnswers = ({ answers, students }) => {
<TextNumberAnswer answers={answers} questionNumber={index} />
</Question>
)
} else if (question.type === 'peerReview') {
return (
<Question key={index} title={question.questionHeader}>
<PeerReviewAnswer answers={answers} questionNumber={index} />
</Question>
)
} else if (question.type === 'radio') {
return (
<Question key={index} title={question.questionHeader}>
@@ -147,6 +153,28 @@ const GroupAnswers = ({ answers, students }) => {
)
}
const PeerReviewAnswer = ({ answers, questionNumber }) => {
return (
<div>
{answers.map((member, index) => {
const peers = member && member.answer_sheet && member.answer_sheet[questionNumber] ? member.answer_sheet[questionNumber].peers : null
return (
<div key={`${member}-${index}`}>
<p>{member.student.last_name}</p>
{peers && Object.entries(peers).map(
([peer, review]) => (
<p key={peer}>
{peer}: {review}
</p>
)
)}
</div>
)
})}
</div>
)
}
const TextNumberAnswer = ({ answers, questionNumber }) => {
return (
<div>
@@ -275,7 +303,7 @@ const DownloadButton = ({ jsonData, fileName }) => {
const SelectViewButton = ({ viewMode, setViewMode }) => {
const toggleView = () => {
setViewMode(viewMode === 'questions' ? 'students' : 'questions');
setViewMode(viewMode === 'questions' ? 'students' : 'questions')
}
return (
@@ -66,10 +66,22 @@ const StudentViewGroupAnswers = ({ answers }) => {
student={studentAnswer.student}
/>
)
} else if (question.type === 'peerReview') {
return (
<div key={questionIndex}>
<h3>{question.questionHeader}</h3>
{question &&
Object.entries(question.peers).map(([peer, review]) => (
<p key={peer}>
{peer}: {review}
</p>
))}
</div>
)
} else {
return (
<div key={questionIndex}>
<h4>{question.questionHeader}</h4>
<h3>{question.questionHeader}</h3>
<p>{question.answer}</p>
</div>
)
@@ -46,3 +46,36 @@
padding: 0;
border-radius: 3px;
}
.peer-review-box__name-text {
margin: 0;
text-align: left;
grid-row: 1;
text-overflow: ellipsis;
overflow: hidden;
}
.peer-review-box__text-field-container {
display: grid;
grid-template-columns: 1fr 3fr;
gap: 24px;
}
.peer-review-box__text-question-container {
display: flex;
flex-direction: column;
gap: 24px;
}
.peer-review-box__text-field-rows {
display: flex;
flex-direction: column;
gap: 16px;
}
.peer-review-box__text-field {
grid-row: 1;
}
.peer-review__description {
}
+101 -16
View File
@@ -80,10 +80,29 @@ class PeerReview extends React.Component {
}
}
const initializePeerReview = (question, questionId) => {
let peerTextAnswers = {
type: 'peerReview',
questionHeader: question.header,
id: questionId,
peers: {},
}
peers.forEach(
(peer) =>
(peerTextAnswers.peers[peer.first_names + ' ' + peer.last_name] =
null)
)
return peerTextAnswers
}
const tempAnswerSheet = questionObject.questions.map(
(question, questionID) => {
if (question.type === 'radio') {
return initializeRadioAnswer(question, questionID)
} else if (question.type === 'peerReview') {
return initializePeerReview(question, questionID)
} else if (question.type === 'text') {
return initializeTextAnswer(question, questionID)
} else if (question.type === 'number') {
@@ -128,6 +147,7 @@ class PeerReview extends React.Component {
const {
answerSheet,
updateAnswer,
updatePeerReview,
peers,
groupsLoading,
submittedReviews,
@@ -167,6 +187,7 @@ class PeerReview extends React.Component {
questions={questionObject.questions}
answerSheet={answerSheet}
updateAnswer={updateAnswer}
updatePeerReview={updatePeerReview}
/>
<div style={{ display: 'flex', justifyContent: 'center' }}>
<Button
@@ -185,7 +206,13 @@ class PeerReview extends React.Component {
}
}
const Questions = ({ questions, peers, answerSheet, updateAnswer }) => {
const Questions = ({
questions,
peers,
answerSheet,
updateAnswer,
updatePeerReview,
}) => {
return (
<div>
{questions.map((question, questionId) => {
@@ -197,6 +224,7 @@ const Questions = ({ questions, peers, answerSheet, updateAnswer }) => {
questionId={questionId}
answerSheet={answerSheet}
updateAnswer={updateAnswer}
updatePeerReview={updatePeerReview}
/>
)
})}
@@ -210,6 +238,7 @@ const Question = ({
questionId,
answerSheet,
updateAnswer,
updatePeerReview,
}) => {
if (question.type === 'radio') {
let temp = question.options
@@ -238,24 +267,42 @@ const Question = ({
</table>
</div>
)
} else if (question.type === 'text') {
} else if (question.type === 'peerReview') {
return (
<div className="peer-review-box">
<h3 className="peer-review-box__h3">{question.header}</h3>
<p>{question.description}</p>
<TextField
value={answerSheet[questionId].answer}
rows="4"
style={{ width: 400 }}
multiline
variant="outlined"
onChange={(e) =>
textFieldHandler(e.target.value, questionId, updateAnswer)
}
<PeerReviewTextFields
peers={peers}
questionId={questionId}
answerSheet={answerSheet}
updatePeerReview={updatePeerReview}
/>
</div>
)
} else if (question.type === 'text') {
return (
<div className="peer-review-box">
<div className="peer-review-box__text-question-container">
<div>
<h3 className="peer-review-box__h3">{question.header}</h3>
<p>{question.description}</p>
<TextField
value={answerSheet[questionId].answer}
rows="4"
style={{ width: 400 }}
multiline
variant="outlined"
onChange={(e) =>
textFieldHandler(e.target.value, questionId, updateAnswer)
}
/>
</div>
</div>
</div>
)
} else if (question.type === 'number') {
return (
<div className="peer-review-box">
@@ -276,7 +323,7 @@ const Question = ({
return (
<div className="peer-review-box">
<h3>{question.header}</h3>
<p>{question.description}</p>
<p className="peer-review__description">{question.description}</p>
</div>
)
} else {
@@ -288,10 +335,6 @@ const Question = ({
}
}
const textFieldHandler = (value, questionId, updateAnswer) => {
updateAnswer(value, questionId)
}
const OptionHeaders = ({ options }) => {
return options.map((option, optionId) => {
return (
@@ -324,6 +367,40 @@ const QuestionRows = ({ peers, options, questionId, answerSheet }) => (
</React.Fragment>
)
const PeerReviewTextFields = ({
peers,
questionId,
answerSheet,
updatePeerReview,
}) => {
return (
<div className="peer-review-box__text-field-rows">
{peers.map((peer, key) => (
<div key={key} className="peer-review-box__text-field-container">
<p className="peer-review-box__name-text">
{extractCallingName(peer.first_names) + ' ' + peer.last_name}:{' '}
</p>
<TextField
className="peer-review-box__text-field"
value={answerSheet[questionId].answer}
rows="2"
multiline
variant="outlined"
onChange={(e) =>
peerReviewHandler(
`${peer.first_names} ${peer.last_name}`,
e.target.value,
questionId,
updatePeerReview
)
}
/>
</div>
))}
</div>
)
}
const QuestionRow = ({
peerName,
options,
@@ -351,6 +428,14 @@ const QuestionRow = ({
)
}
const peerReviewHandler = (peerId, value, questionId, updatePeerReview) => {
updatePeerReview(peerId, value, questionId)
}
const textFieldHandler = (value, questionId, updateAnswer) => {
updateAnswer(value, questionId)
}
const radioSelectHandler = (peerId, buttonId, answerSheet) => {
updateAnswers(peerId, buttonId, answerSheet)
}
+36 -5
View File
@@ -18,7 +18,7 @@ const QuestionSetForm = ({
initialName,
initialQuestionsJson,
onSubmit,
controls
controls,
}) => {
const [name, setName] = useState(initialName || '')
const [questionsJson, setQuestionsJson] = useState(initialQuestionsJson || '')
@@ -55,7 +55,7 @@ const QuestionSetForm = ({
<form className="question-set-form" onSubmit={handleFormSubmit}>
<TextField
inputProps={{
className: 'question-set-form__name'
className: 'question-set-form__name',
}}
fullWidth
required
@@ -70,9 +70,9 @@ const QuestionSetForm = ({
style: {
fontFamily:
'source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace',
fontSize: '0.9em'
fontSize: '0.9em',
},
className: 'question-set-form__questions'
className: 'question-set-form__questions',
}}
fullWidth
required
@@ -80,9 +80,40 @@ const QuestionSetForm = ({
helperText={questionsError || ''}
margin="normal"
multiline
rows={10}
rows={28}
value={questionsJson}
onChange={handleQuestionsChange}
placeholder={`
Available question types:
- number
- radio
- peerReview (individual text fields for each team member)
- text (single text field)
For example:
[
{
"type": "number",
"header": "Kuinka monta tuntia käytit projektin parissa?",
"description": "Arvioi käyttämäsi aika tunteina."
},
{
"type": "radio",
"header": "Tiimin jäsenten tekninen kontribuutio",
"options": [1, 2, 3, 4, 5]
},
{
"type": "peerReview",
"header": "Teknisen kontribuution arviointi",
"description": "Arvioi sanallisesti tiimin jäsenten teknistä kontribuutiota."
}
]
`}
InputLabelProps={{
shrink: true,
}}
/>
{controls}
</form>
@@ -1,7 +1,7 @@
const initializeAnswerSheet = (answerSheet) => {
return {
type: 'INITIALIZE_ANSWER_SHEET',
payload: answerSheet
payload: answerSheet,
}
}
@@ -9,48 +9,57 @@ const updateAnswer = (answer, questionId) => {
return {
type: 'UPDATE_ANSWER',
answer: answer,
questionId: questionId
questionId: questionId,
}
}
const updatePeerReview = (peerId, answer, questionId) => {
return {
type: 'UPDATE_PEER_REVIEW',
answer: answer,
peerId: peerId,
questionId: questionId,
}
}
const createPeers = (peers) => {
return {
type: 'CREATE_PEERS',
payload: peers
payload: peers,
}
}
const setSubmittedReviews = (submittedReviews) => {
return {
type: 'SET_SUBMITTED_REVIEWS',
payload: submittedReviews
payload: submittedReviews,
}
}
const setLoading = (loading) => {
return {
type: 'LOADING',
payload: loading
payload: loading,
}
}
const setQuestions = (questions) => {
return {
type: 'SET_QUESTIONS',
payload: questions
payload: questions,
}
}
const setConfiguration = (configurationId) => {
return {
type: 'SET_CONFIGURATION',
payload: configurationId
payload: configurationId,
}
}
export default {
updateAnswer,
updatePeerReview,
initializeAnswerSheet,
createPeers,
setSubmittedReviews,
setLoading,
setQuestions,
setConfiguration
setConfiguration,
}
@@ -28,6 +28,21 @@ const peerReviewReducer = (state = initialState, action) => {
...state.answerSheet.slice(action.questionId + 1)
]
}
case 'UPDATE_PEER_REVIEW':
return {
...state,
answerSheet: [
...state.answerSheet.slice(0, action.questionId),
{
...state.answerSheet[action.questionId],
peers: {
...state.answerSheet[action.questionId].peers,
[action.peerId]: action.answer
},
},
...state.answerSheet.slice(action.questionId + 1),
],
}
case 'CREATE_PEERS':
return {
...state,