@instructure/quiz-taking
Version:
357 lines (321 loc) • 11.3 kB
JavaScript
/** @jsx jsx */
import React, {Component} from 'react'
import PropTypes from 'prop-types'
import {scoreToLetterGrade} from '@instructure/grading-utils'
import {jsx} from '@instructure/emotion'
import {Heading} from '@instructure/ui-heading'
import {Text} from '@instructure/ui-text'
import {Link} from '@instructure/ui-link'
import {AccessibleContent} from '@instructure/ui-a11y-content'
import {Table} from '@instructure/ui-table'
import {ToggleDetails} from '@instructure/ui-toggle-details'
import {Tooltip} from '@instructure/ui-tooltip'
import {Focusable} from '@instructure/ui-focusable'
import {View} from '@instructure/ui-view'
import {Responsive} from '@instructure/ui-responsive'
import {IconOffLine} from '@instructure/ui-icons'
import {CustomPropTypes} from '@instructure/quiz-core/common/util/CustomPropTypes'
import generateStyle from './styles'
import generateComponentTheme from './theme'
import t from '@instructure/quiz-i18n/format-message'
import {formatPercentMax2FractionDigits} from '@instructure/quiz-i18n/util/numberFormats'
import {withI18nSupport} from '@instructure/quiz-common/with-i18n-support'
import {withStyleOverrides} from '@instructure/quiz-common/util/withStyleOverrides'
class BaseAttemptHistory extends withI18nSupport(Component) {
static displayName = 'AttemptHistory'
static componentId = `Quizzes${this.displayName}`
static propTypes = {
attemptHistory: CustomPropTypes.attemptHistory,
averageScore: CustomPropTypes.numberInRange(0, 1),
getQuizSessions: PropTypes.func.isRequired,
gradingScheme: PropTypes.arrayOf(
PropTypes.shape({
name: PropTypes.string,
value: PropTypes.number,
}),
),
onAttemptClick: PropTypes.func,
quizId: CustomPropTypes.recordId,
restrictQuantitativeData: PropTypes.bool,
isSurvey: PropTypes.bool.isRequired,
scoreToKeep: PropTypes.oneOf(['highest', 'latest', 'average', 'first']).isRequired,
selectedSessionId: PropTypes.string.isRequired,
/* eslint-disable-next-line react/forbid-prop-types */
styles: PropTypes.object,
}
static defaultProps = {
attemptHistory: null,
gradingScheme: [],
/* v8 ignore start */
onAttemptClick: () => {},
/* v8 ignore end */
restrictQuantitativeData: false,
}
// =============
// LIFECYCLE
// =============
componentDidMount() {
if (this.props.attemptHistory) {
this.fetchData()
}
}
componentDidUpdate(prevProps) {
if (
this.props.attemptHistory &&
(!prevProps.attemptHistory ||
this.props.attemptHistory.some(
(attempt, index) =>
attempt.quizSessionId !== prevProps.attemptHistory[index].quizSessionId,
))
) {
this.fetchData()
}
}
fetchData() {
const {getQuizSessions, quizId, attemptHistory} = this.props
const quizSessionIds = attemptHistory.map(attempt => attempt.quizSessionId).filter(Boolean)
if (!quizSessionIds.length) return
getQuizSessions(quizId, {ids: quizSessionIds})
}
get scoreToKeepString() {
return {
highest: t('Highest'),
latest: t('Latest'),
average: t('Average'),
first: t('First'),
}[this.props.scoreToKeep]
}
// =============
// Callbacks
// =============
onAttemptClick(quizSessionId) {
return () => {
this.props.onAttemptClick(quizSessionId)
}
}
// =============
// RENDERING
// =============
renderAttemptLink(attempt, i) {
const {displayName, quizSessionId} = attempt
const attemptName = displayName || t('Attempt {n}', {n: i + 1})
const fontStyles =
this.props.selectedSessionId === quizSessionId
? this.props.styles.bold
: this.props.styles.normal
if (this.props.onAttemptClick && quizSessionId) {
return (
<Link onClick={this.onAttemptClick(quizSessionId)}>
<span css={fontStyles}>{attemptName}</span>
</Link>
)
} else {
return attemptName
}
}
renderHiddenAttemptInfo() {
const text = t('Not displayed per instructor settings.')
return (
<AccessibleContent alt={text}>
<Tooltip renderTip={text} placement="top" mountNode={this.getMountNode}>
<span>
<Focusable>
{({focusVisible}) => (
<View as="div" withFocusOutline={focusVisible}>
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex */}
<span css={this.props.styles.hiddenIcon} tabIndex="0">
<IconOffLine size="x-small" />
</span>
</View>
)}
</Focusable>
</span>
</Tooltip>
</AccessibleContent>
)
}
getMountNode() {
return document.getElementById('main')
}
renderAttemptHistory() {
const {isSurvey, restrictQuantitativeData} = this.props
return this.props.attemptHistory.map((attempt, i) => {
let points = null
let score = null
let letterGrade = null
if (attempt.score != null) {
if (attempt.pointsPossible) {
points = t('{score, number} of {total, number}', {
score: attempt.score,
total: attempt.pointsPossible,
})
if (attempt.percentage == null) {
score = this.formatPercentMax2FractionDigits(attempt.score / attempt.pointsPossible)
if (restrictQuantitativeData) {
letterGrade = scoreToLetterGrade(
(attempt.score / attempt.pointsPossible) * 100,
this.props.gradingScheme,
)
}
}
} else {
points = t.number(attempt.score)
}
}
if (attempt.percentage != null) {
score = this.formatPercentMax2FractionDigits(attempt.percentage)
if (restrictQuantitativeData) {
letterGrade = scoreToLetterGrade(attempt.percentage * 100, this.props.gradingScheme)
}
}
const fontStyles =
this.props.selectedSessionId === attempt.quizSessionId
? this.props.styles.bold
: this.props.styles.normal
return (
<Table.Row key={JSON.stringify(attempt)}>
<Table.Cell>
<span css={fontStyles}>{this.renderAttemptLink(attempt, i)}</span>
</Table.Cell>
{!isSurvey && !restrictQuantitativeData && (
<Table.Cell>
<span css={fontStyles}>{points || this.renderHiddenAttemptInfo()}</span>
</Table.Cell>
)}
{!isSurvey && !restrictQuantitativeData && (
<Table.Cell>
<span css={fontStyles}>{score || this.renderHiddenAttemptInfo()}</span>
</Table.Cell>
)}
{!isSurvey && restrictQuantitativeData && (
<Table.Cell>
<span css={fontStyles}>{letterGrade || this.renderHiddenAttemptInfo()}</span>
</Table.Cell>
)}
{!isSurvey && (
<Table.Cell>
<span css={fontStyles}>{this.renderScoreBody(attempt)}</span>
</Table.Cell>
)}
</Table.Row>
)
})
}
renderScoreBody(attempt) {
if (
typeof attempt.score !== 'undefined' &&
attempt.authoritative &&
this.props.scoreToKeep !== 'average'
) {
return t('({scoreType} score)', {scoreType: this.scoreToKeepString})
}
}
renderAverageScore() {
const {averageScore, isSurvey} = this.props
if (isSurvey || !averageScore) return null
const average = this.props.restrictQuantitativeData
? scoreToLetterGrade(averageScore * 100, this.props.gradingScheme)
: formatPercentMax2FractionDigits(averageScore)
return (
<Table.Row>
<Table.Cell>
<span css={this.props.styles.bold}>{t('Average Score')}</span>
</Table.Cell>
{!this.props.restrictQuantitativeData && (
<Table.Cell>
<span css={this.props.styles.bold}>
{'--' /* eslint-disable-line react/jsx-no-literals */}
</span>
</Table.Cell>
)}
<Table.Cell>
<span css={this.props.styles.bold}>{average}</span>
</Table.Cell>
<Table.Cell></Table.Cell>
</Table.Row>
)
}
renderTable() {
const {restrictQuantitativeData, isSurvey} = this.props
return (
<div css={this.props.styles.sectionWrapper}>
<ToggleDetails
defaultExpanded
summary={
<Heading level="h2" color="primary">
{t('Attempt History')}
</Heading>
}
>
<Responsive
query={{
small: {maxWidth: '20rem'},
large: {minWidth: '21rem'},
}}
props={{
small: {layout: 'stacked'},
large: {layout: 'auto'},
}}
>
{props => (
// Column header fallbacks are React Fragments because InstUI doesn't filter out falsy
// values from the list of Children and the app crashes
// Related ticket: https://instructure.atlassian.net/browse/INSTUI-4534
<Table caption={t('Attempt History')} {...props}>
<Table.Head>
<Table.Row>
<Table.ColHeader id="attempt-history-results">{t('Results')}</Table.ColHeader>
{!isSurvey && !restrictQuantitativeData ? (
<Table.ColHeader id="attempt-history-points">{t('Points')}</Table.ColHeader>
) : (
<></>
)}
{!isSurvey && !restrictQuantitativeData ? (
<Table.ColHeader id="attempt-history-score">{t('Score')}</Table.ColHeader>
) : (
<></>
)}
{!isSurvey && restrictQuantitativeData ? (
<Table.ColHeader id="attempt-history-grade">{t('Grade')}</Table.ColHeader>
) : (
<></>
)}
{!isSurvey && (
<Table.ColHeader id="attempt-history-score-to-keep">
{t('({scoreToKeep} score is kept)', {scoreToKeep: this.scoreToKeepString})}
</Table.ColHeader>
)}
</Table.Row>
</Table.Head>
<Table.Body>
{this.renderAttemptHistory()}
{this.renderAverageScore()}
</Table.Body>
</Table>
)}
</Responsive>
</ToggleDetails>
</div>
)
}
render() {
const {attemptHistory} = this.props
if (!attemptHistory) return null
if (attemptHistory.length > 0) {
return this.renderTable()
} else {
return (
<div css={this.props.styles.page}>
<span css={this.props.styles.emptyText}>
<Text color="primary">{t('No attempt information to display yet.')}</Text>
</span>
</div>
)
}
}
}
export const AttemptHistory = withStyleOverrides(
generateStyle,
generateComponentTheme,
)(BaseAttemptHistory)
export default AttemptHistory