UNPKG

@instructure/quiz-taking

Version:
357 lines (321 loc) • 11.3 kB
/** @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