@instructure/quiz-interactions
Version:
A React UI component Library for quiz interaction types.
346 lines (310 loc) • 11.5 kB
JavaScript
/** @jsx jsx */
import {Component} from 'react'
import PropTypes from 'prop-types'
import get from 'lodash/fp/get'
import {Text} from '@instructure/ui-text'
import {jsx} from '@instructure/emotion'
import t from '@instructure/quiz-i18n/format-message'
import {ItemBodyWrapper} from '@instructure/quiz-rce/components/ItemBodyWrapper/index'
import {FeedbackWrapper} from '@instructure/quiz-results-feedback'
import CategoriesContainer from '../common/CategoriesContainer'
import {getSortedCategories} from '../common/utils'
import generateStyle from './styles'
import generateComponentTheme from './theme'
import {withStyleOverrides} from '@instructure/quiz-common/util/withStyleOverrides'
/**
---
category: Categorization
---
Categorization Result component
```jsx_example
<SettingsSwitcher locales={LOCALES}>
<CategorizationResult
itemBody="Match the name of the celestial body on the left with its correct classification:"
interactionData={{
categoryOrder: ['category_uuid1', 'category_uuid2', 'category_uuid3', 'category_uuid4'],
categories: {
category_uuid1: { id: 'category_uuid1', itemBody: 'Planet' },
category_uuid2: { id: 'category_uuid2', itemBody: 'Moon' },
category_uuid3: { id: 'category_uuid3', itemBody: 'Quasar' },
category_uuid4: { id: 'category_uuid4', itemBody: 'Stars' },
},
distractors: {
answer_uuid3: { id: 'answer_uuid3', itemBody: 'Mars' },
answer_uuid4: { id: 'answer_uuid4', itemBody: 'Europa' },
answer_uuid5: { id: 'answer_uuid5', itemBody: 'Venus' },
answer_uuid6: { id: 'answer_uuid6', itemBody: 'Phobos' },
answer_uuid7: { id: 'answer_uuid7', itemBody: 'Deimos' },
answer_uuid8: { id: 'answer_uuid8', itemBody: 'Jupiter' },
answer_uuid9: { id: 'answer_uuid9', itemBody: 'America' },
answer_uuid10: { id: 'answer_uuid10', itemBody: 'Asia' },
answer_uuid11: { id: 'answer_uuid11', itemBody: 'The Sun' }
}
}}
scoredData={{
value: {
category_uuid1: {
value: {
answer_uuid3: { resultScore: 1, userResponded: false },
answer_uuid5: { resultScore: 1, userResponded: true },
answer_uuid8: { resultScore: 1, userResponded: false },
answer_uuid7: { resultScore: 0, userResponded: true }
}
},
category_uuid3: {
value: {}
},
category_uuid4: {
value: {
answer_uuid11: { resultScore: 1, userResponded: false }
}
},
category_uuid2: {
value: {
answer_uuid4: { resultScore: 1, userResponded: true },
answer_uuid6: { resultScore: 1, userResponded: true },
answer_uuid7: { resultScore: 1, userResponded: false },
answer_uuid8: { resultScore: 0, userResponded: true },
answer_uuid9: { resultScore: 0, userResponded: true }
}
}
}
}}
/>
</SettingsSwitcher>
```
**/
export default class CategorizationResult extends Component {
static displayName = 'CategorizationResult'
static componentId = `Quizzes${this.displayName}`
static propTypes = {
interactionData: PropTypes.object.isRequired,
itemBody: PropTypes.string.isRequired,
scoredData: PropTypes.shape({
correct: PropTypes.bool,
uncategorized: PropTypes.objectOf(
PropTypes.shape({
resultScore: PropTypes.number,
userResponded: PropTypes.bool,
}),
),
value: PropTypes.objectOf(
PropTypes.shape({
value: PropTypes.objectOf(
PropTypes.shape({
resultScore: PropTypes.number,
userResponded: PropTypes.bool,
}),
),
}),
),
}).isRequired,
styles: PropTypes.object,
}
constructor(props) {
super(props)
const {categories, categoryOrder} = props.interactionData
this.state = {
sortedCategories: getSortedCategories(categories, categoryOrder),
}
}
// ===========
// UTILS
// ===========
hasMissedCorrectAnswerInObject(answers) {
return (
answers &&
Object.keys(answers).some(answerId => {
return (
answers[answerId] &&
answers[answerId].resultScore > 0 &&
answers[answerId].userResponded === false
)
})
)
}
hasMissedCorrectAnswerInUncategorized() {
const {uncategorized} = this.props.scoredData
return this.hasMissedCorrectAnswerInObject(uncategorized)
}
hasMissedCorrectAnswerInCategories() {
const categories = this.props.scoredData.value
return (
categories &&
Object.keys(categories).some(categoryKey => {
const answers = categories[categoryKey].value
return this.hasMissedCorrectAnswerInObject(answers)
})
)
}
includesAllCorrectAnswers() {
// we know a response includes all the correct answers if it is either
// correct, or it contains correct responses not chosen by the user
const {scoredData} = this.props
if (scoredData.correct) {
return true
}
return this.hasMissedCorrectAnswerInUncategorized() || this.hasMissedCorrectAnswerInCategories()
}
getCorrectAnswer(answerId, categoryId) {
const {scoredData} = this.props
const correctCategoryId =
scoredData.value &&
Object.keys(scoredData.value).find(categoryKey => {
if (categoryKey !== categoryId) {
return get(`value[${categoryKey}].value[${answerId}].resultScore`, scoredData)
} else {
return null
}
})
if (correctCategoryId) {
return this.props.interactionData.categories[correctCategoryId].itemBody
}
}
getUnansweredAnswersIds() {
if (this.responseHidden()) return []
const distractorsArray = Object.keys(this.props.interactionData.distractors)
return distractorsArray.filter(answerId => {
const category = Object.values(this.props.scoredData.value).find(scoredDataCateg =>
get(`value[${answerId}].userResponded`, scoredDataCateg),
)
return category === void 0
})
}
responseHidden() {
const {scoredData} = this.props
return !scoredData.value || Object.keys(scoredData.value).length === 0
}
correctnessKnown() {
return this.props.scoredData.correct != null
}
// ===========
// RENDER
// ===========
renderCategoryAnswer(answerId, resultScore, categoryId) {
let status, correctAnswerText
if (resultScore > 0) {
status = 'correct'
} else if (resultScore <= 0) {
status = 'incorrect'
if (this.correctnessKnown()) {
correctAnswerText = this.getCorrectAnswer(answerId)
if (!correctAnswerText) {
// If a correct answer can't be found, it means that either the
// answer should have been left uncategorized, or the results are
// restricted and the correct answer is hidden. In the first case, we
// want to display "No Category" feedback, but in the latter case we
// don't want to provide any feedback beyond whether the answer was
// correct. To distinguish between these 2 cases, we check whether
// any missed correct answers are present in the response, and whether
// the `uncategorized` scoredData attribute exists, which is essentially a
// category for answers that should remain uncategorized. If the
// `uncategorized` attribute is absent, but we show other correct answers,
// we display the "No Category" feedback for backward-compatibility.
const {uncategorized} = this.props.scoredData
if (
this.includesAllCorrectAnswers() &&
(!uncategorized || get(`${answerId}.resultScore`, uncategorized))
) {
correctAnswerText = t('No Category')
}
}
}
} else {
status = 'unknown'
}
return (
<div key={answerId} css={this.props.styles.feedbackWrapper}>
<FeedbackWrapper
correctAnswer={correctAnswerText}
hiddenCorrectAnswerText={t('Correct answer: ')}
hiddenIncorrectAnswerText={t('Incorrect answer: ')}
status={status}
>
<div css={this.props.styles.userResponse}>
<Text color="primary">{this.props.interactionData.distractors[answerId].itemBody}</Text>
</div>
</FeedbackWrapper>
</div>
)
}
renderCategoryBody = categoryId => {
const categoryValue = get(`scoredData.value[${categoryId}].value`, this.props) || {}
const answers = Object.entries(categoryValue).filter(([_, answer]) => answer.userResponded)
if (answers.length) {
return (
<div className="categoryBody fs-mask" css={this.props.styles.categoryBody}>
{answers.map(([answerId, {resultScore}]) =>
this.renderCategoryAnswer(answerId, resultScore, categoryId),
)}
</div>
)
}
return (
<div className="categoryBody fs-mask" css={this.props.styles.categoryBody}>
<div css={this.props.styles.noChoicesText}>
<Text color="primary">
{this.responseHidden() ? t('Choices not displayed') : t('No Choices Selected')}
</Text>
</div>
</div>
)
}
renderUnansweredChoice = answerId => {
const {itemBody} = this.props.interactionData.distractors[answerId]
let status = 'unknown',
correctAnswerText
if (this.correctnessKnown()) {
const {scoredData} = this.props
correctAnswerText = this.getCorrectAnswer(answerId)
if (correctAnswerText) {
status = 'incorrect'
} else if (!scoredData.uncategorized) {
if (this.includesAllCorrectAnswers()) {
status = 'correct' // for backward-compatibility
} else {
status = 'unknown'
}
} else {
status = get(`uncategorized[${answerId}].resultScore`, scoredData) ? 'correct' : 'incorrect'
}
}
return (
<div key={answerId} css={this.props.styles.unanswered}>
<div css={this.props.styles.feedbackWrapper}>
<FeedbackWrapper
correctAnswer={correctAnswerText}
hiddenCorrectAnswerText={t('Correct answer: ')}
hiddenIncorrectAnswerText={t('Incorrect answer: ')}
status={status}
>
<div css={this.props.styles.userResponse}>
<Text color="primary">{itemBody}</Text>
</div>
</FeedbackWrapper>
</div>
</div>
)
}
render() {
const unansweredList = this.getUnansweredAnswersIds()
return (
<ItemBodyWrapper itemBody={this.props.itemBody}>
<CategoriesContainer
sortedCategories={this.state.sortedCategories}
categoryBody={this.renderCategoryBody}
distractors={this.props.interactionData.distractors}
/>
{unansweredList.length === 0 ? null : (
<div css={this.props.styles.mainContainer}>
<Text color="primary">{t('Uncategorized answers')}</Text>
<div className="categoryBody fs-mask" css={this.props.styles.categoryBody}>
{unansweredList.map(this.renderUnansweredChoice)}
</div>
</div>
)}
</ItemBodyWrapper>
)
}
}