@instructure/quiz-grading
Version:
The Quiz React SDK by Instructure Inc.
469 lines (416 loc) • 14.2 kB
JavaScript
/** @jsx jsx */
import {Component} from 'react'
import PropTypes from 'prop-types'
import ImmutablePropTypes from 'react-immutable-proptypes'
import {Map} from 'immutable'
import debounce from 'lodash/debounce'
import {
IconArrowOpenEndLine,
IconArrowOpenStartLine,
IconCheckLine,
IconDiscussionLine,
IconDiscussionSolid,
IconXLine,
} from '@instructure/ui-icons'
import {Button} from '@instructure/ui-buttons'
import NumberInput from '@instructure/quiz-number-input/components/NumberInput/index'
import {Text} from '@instructure/ui-text'
import {View} from '@instructure/ui-view'
import {PresentationContent, ScreenReaderContent} from '@instructure/ui-a11y-content'
import canvas from '@instructure/ui-themes'
import {jsx} from '@instructure/emotion'
import t from '@instructure/quiz-i18n/format-message'
import {Flex} from '@instructure/quiz-common/components/Flex/index'
import {HideableButton} from '@instructure/quiz-common/components/HideableButton/index'
import {
GRADE_BY_QUESTION_PREVIOUS,
GRADE_BY_QUESTION_NEXT,
} from '@instructure/quiz-common/constants'
import {withStyleOverrides} from '@instructure/quiz-common/util/withStyleOverrides'
import {RichContentInput} from '@instructure/quiz-rce/components/RichContentInput/index'
import generateStyle from './styles'
import generateComponentTheme from './theme'
export class BaseGradingBar extends Component {
static displayName = 'GradingBar'
static componentId = `Quizzes${this.displayName}`
static propTypes = {
activeItemId: PropTypes.string,
gradeByQuestionEnabled: PropTypes.bool,
graderFeedback: ImmutablePropTypes.map.isRequired,
handleChangeGradeStatus: PropTypes.func.isRequired,
handleChangePoints: PropTypes.func.isRequired,
isAutogradeable: PropTypes.bool.isRequired,
isEditing: PropTypes.bool.isRequired,
isSubjective: PropTypes.bool.isRequired,
itemResultsModifications: ImmutablePropTypes.map.isRequired,
postToParent: PropTypes.func.isRequired,
regradeToggleAction: PropTypes.func.isRequired,
renderUpdateButton: PropTypes.func.isRequired,
sessionItem: ImmutablePropTypes.record.isRequired,
sessionItemResult: ImmutablePropTypes.record.isRequired,
startFeedbackEdit: PropTypes.func.isRequired,
submitFeedbackEdit: PropTypes.func.isRequired,
updateFeedbackEdit: PropTypes.func.isRequired,
cancelFeedbackEdit: PropTypes.func.isRequired,
styles: PropTypes.object,
regradingFromSpeedGrader: PropTypes.bool,
isSurvey: PropTypes.bool.isRequired,
}
static defaultProps = {
activeItemId: null,
gradeByQuestionEnabled: false,
}
regradeButtonRef = null
feedbackButtonRef = null
_timeouts = []
constructor(props) {
super(props)
let points = this.props.sessionItemResult.getWorkingInstance().score
if (typeof points === 'number' || typeof points === 'string') {
points = Number(points)
} else {
points = null
}
this.state = {
points,
normalizedPoints: points,
}
}
// =============
// EXTERNALLY USED UTILS
// =============
focusRegradeButton = () => {
this.regradeButtonRef && this.regradeButtonRef.focus()
}
focusFeedbackButton = () => {
this.feedbackButtonRef && this.feedbackButtonRef.focus()
}
// =============
// INTERNAL UTILS
// =============
get regradeInfo() {
return this.props.sessionItemResult.regradeInfo
}
hasRegradeInfo() {
return this.regradeInfo && this.regradeInfo.size > 1
}
pointsPossibleDescription(pointsPossible) {
return t(
`{
points, plural,
one {# point}
other {# points}
}`,
{points: pointsPossible},
)
}
getGradeStatus() {
const resultMods = this.props.itemResultsModifications || Map()
const workingGradeStatus = resultMods.getIn(['scoredData', 'gradeStatus'])
if (workingGradeStatus) {
return workingGradeStatus
}
return this.props.sessionItemResult.getIn(['scoredData', 'gradeStatus'])
}
// =============
// HANDLERS
// =============
updatePoints = debounce(
() => {
this.props.handleChangePoints(this.state.normalizedPoints)
},
250,
{leading: true, trailing: true},
)
handleChangePoints = (event, points, normalizedPoints) => {
const parsedPoints = parseFloat(normalizedPoints) || 0
this.setState({points, normalizedPoints: parsedPoints}, this.updatePoints)
const gradeStatus = this.getGradeStatus()
if (!gradeStatus || gradeStatus === 'waiting') {
this.props.handleChangeGradeStatus('graded')
}
}
handleBlurPoints = () => {
this.updatePoints.flush()
this.setState(({normalizedPoints}) => {
return {points: normalizedPoints || 0}
})
}
handleStatusCorrectToggle = () => {
const newPoints = +this.props.sessionItemResult.pointsPossible
this.props.handleChangePoints(newPoints)
this.setState({
points: `${newPoints}`,
normalizedPoints: `${newPoints}`,
})
const newStatus = this.getGradeStatus() === 'correct' ? 'graded' : 'correct'
this.props.handleChangeGradeStatus(newStatus, newStatus === 'graded')
}
handleStatusIncorrectToggle = () => {
this.props.handleChangePoints(0)
this.setState({
points: '0',
normalizedPoints: '0',
})
const newStatus = this.getGradeStatus() === 'incorrect' ? 'graded' : 'incorrect'
this.props.handleChangeGradeStatus(newStatus, newStatus === 'graded')
}
previousStudent = e => {
e.preventDefault()
this.props.postToParent({subject: GRADE_BY_QUESTION_PREVIOUS})
}
nextStudent = e => {
e.preventDefault()
this.props.postToParent({subject: GRADE_BY_QUESTION_NEXT})
}
jumpToSidebar = e => {
e.preventDefault()
const sidebarWrapper = document.getElementById('sidebar-wrapper')
if (sidebarWrapper && sidebarWrapper.focus) {
sidebarWrapper.focus()
}
}
handleRegradeButtonRef = node => {
this.regradeButtonRef = node
}
handleFeedbackButtonRef = node => {
this.feedbackButtonRef = node
}
// =============
// RENDERING
// =============
renderToSidebarButton() {
return (
<Flex justifyItems="end" margin="small medium">
<HideableButton href="#" onClick={this.jumpToSidebar}>
{t('Jump to Question Navigator')}
</HideableButton>
</Flex>
)
}
renderSubjectiveButtons() {
if (this.props.isSubjective) {
const correctVariant = this.getGradeStatus() === 'correct' ? 'success' : 'primary-inverse'
const correctText =
this.getGradeStatus() === 'correct' ? t('toggle correct off') : t('toggle correct on')
const incorrectVariant = this.getGradeStatus() === 'incorrect' ? 'danger' : 'primary-inverse'
const incorrectText =
this.getGradeStatus() === 'incorrect' ? t('toggle incorrect off') : t('toggle incorrect on')
return (
<div css={this.props.styles.subjectiveButtonsWrapper}>
<Button
color={correctVariant}
onClick={this.handleStatusCorrectToggle}
renderIcon={IconCheckLine}
>
<ScreenReaderContent>{correctText}</ScreenReaderContent>
</Button>
<Button
color={incorrectVariant}
margin="0 xx-small"
onClick={this.handleStatusIncorrectToggle}
renderIcon={IconXLine}
>
<ScreenReaderContent>{incorrectText}</ScreenReaderContent>
</Button>
</div>
)
}
}
renderFeedbackSection() {
const workingSessionItemResult = this.props.sessionItemResult.getWorkingInstance()
const graderFeedbackContent = workingSessionItemResult.getIn(
['feedback', 'graderFeedback', 'content'],
'',
)
let icon, text
if (graderFeedbackContent) {
icon = IconDiscussionSolid
text = t('Edit Grader Level Feedback (present)')
} else {
icon = IconDiscussionLine
text = t('Edit Grader Level Feedback')
}
return (
<Button
ref={this.handleFeedbackButtonRef}
onClick={this.props.startFeedbackEdit}
renderIcon={icon}
data-automation="sdk-grading-edit-feedback-button"
>
<ScreenReaderContent>{text}</ScreenReaderContent>
</Button>
)
}
renderRegradeInfo() {
return (
<div css={this.props.styles.regradeParent}>
<div css={this.props.styles.originalScore}>
<Text color="secondary">
{t(
`Previous score { possible, plural,
one {{points, number} / # point}
other {{points, number} / # points}
}`,
{
points: this.regradeInfo.get('originalScore'),
possible: this.regradeInfo.get('originalPointsPossible'),
},
)}
</Text>
<PresentationContent>
<div css={this.props.styles.divider}>
<Text color="secondary">{'|' /* eslint-disable-line react/jsx-no-literals */}</Text>
</div>
</PresentationContent>
</div>
<div css={this.props.styles.regradeText}>
<Text color="alert">{t('Regrade score:')}</Text>
</div>
{this.renderNumberInput()}
<Text color="alert">{
// eslint-disable-next-line react/jsx-no-literals
`/ ${this.pointsPossibleDescription(this.regradeInfo.get('newPointsPossible'))}`
}</Text>
</div>
)
}
renderNumberInput() {
return (
<div css={this.props.styles.points}>
<NumberInput
placeholder="--"
renderLabel={<ScreenReaderContent>{t('Edit Score')}</ScreenReaderContent>}
onChange={this.handleChangePoints}
onBlur={this.handleBlurPoints}
value={this.state.points}
width="6rem"
data-automation="sdk-grading-edit-score-input"
/>
</div>
)
}
renderDefaultNumberInput() {
return (
<div css={this.props.styles.defaultNumberInput}>
{this.renderNumberInput()}
<span css={this.props.styles.pointsPossible}>{
// eslint-disable-next-line react/jsx-no-literals
`/ ${this.pointsPossibleDescription(this.props.sessionItemResult.pointsPossible)}`
}</span>
</div>
)
}
renderRegradeButton() {
if (
!this.props.isAutogradeable ||
!this.props.regradingFromSpeedGrader ||
this.props.isSurvey
) {
return null
}
return (
<div css={this.props.styles.regradeButtonWrapper}>
<Button
margin="0 small"
onClick={this.props.regradeToggleAction}
ref={this.handleRegradeButtonRef}
data-automation="sdk-regrade-request-button"
>
{t.rich('Regrade <0>question {position, number}</0>', {
0: ({children}) => <ScreenReaderContent key="0">{children}</ScreenReaderContent>,
position: this.props.sessionItem.position,
})}
</Button>
</div>
)
}
renderEditGraderFeedback() {
if (!this.props.isEditing) {
return
}
const textareaId = `edit_grader_feedback_${this.props.sessionItem.id}`
return (
<div css={this.props.styles.editFeedback}>
<label htmlFor="edit_grader_feedback">{t('Additional Comments')}</label>
<RichContentInput
label=""
defaultContent={this.props.graderFeedback.get('content')}
onKeyUp={this.props.updateFeedbackEdit}
onChange={this.props.updateFeedbackEdit}
textareaId={textareaId}
/>
<div className="editFeedbackButtons" css={this.props.styles.editFeedbackButtons}>
<Button
size="small"
margin="x-small"
onClick={this.props.cancelFeedbackEdit}
data-automation="sdk-grading-edit-feedback-cancel"
>
{t('Cancel')}
</Button>
<Button
color="primary"
size="small"
margin="x-small"
onClick={this.props.submitFeedbackEdit}
data-automation="sdk-grading-edit-feedback-submit"
>
{t('Done')}
</Button>
</div>
</div>
)
}
renderGradeByQuestionControls() {
const {activeItemId, gradeByQuestionEnabled, sessionItem} = this.props
if (gradeByQuestionEnabled && sessionItem.itemRecord.id === activeItemId) {
return (
<Flex justifyItems="end" margin="small medium">
{this.props.renderUpdateButton({margin: '0 xx-small'})}
<Button margin="0 xx-small" href="#" onClick={this.previousStudent}>
<Flex alignItems="center">
<IconArrowOpenStartLine />
<View margin="0 0 0 x-small">{t('Previous Student')}</View>
</Flex>
</Button>
<Button margin="0 xx-small" href="#" onClick={this.nextStudent}>
<Flex alignItems="center">
<View margin="0 x-small 0 0">{t('Next Student')}</View>
<IconArrowOpenEndLine />
</Flex>
</Button>
</Flex>
)
}
}
render() {
const {isSurvey} = this.props
return (
<div>
{this.renderEditGraderFeedback()}
<View
as="div"
borderWidth="small none none none"
margin="0 medium"
borderColor={canvas.colors.contrasts.grey1111}
>
<Flex as="div" alignItems="center" wrap="wrap">
<div css={this.props.styles.feedbackButtonWrapper}>{this.renderFeedbackSection()}</div>
{this.renderRegradeButton()}
{!isSurvey && (
<div className="fs-mask" css={this.props.styles.pointsSection}>
{this.renderSubjectiveButtons()}
{this.hasRegradeInfo() ? this.renderRegradeInfo() : this.renderDefaultNumberInput()}
</div>
)}
</Flex>
</View>
{this.renderGradeByQuestionControls()}
{this.renderToSidebarButton()}
</div>
)
}
}
export const GradingBar = withStyleOverrides(generateStyle, generateComponentTheme)(BaseGradingBar)
export default GradingBar