update styles and accessibility
closes GRADE-1001 test plan: A. Setup 1. Setup a course with AMM 2. Add a moderated assignment with: a. Two graders (A and B) b. A third, final grader (C) 3. Ensure enrollment of at least 21 students 4. Assign some provisional grades using graders A and B 5. Log in as the final grader C 6. Visit the moderation page for the assignment B. Verify VoiceOver (in Safari) * This could be deferred to the a11y audit 1. Verify the table is accessible using VoiceOver a. Announces table name "Grade Selection Table" b. Announces row and column headers for cells c. Announces cell content 2. Verify Post button accessibility 3. Verify Display to Students button accessibility 4. Verify Pagination accessibility C. Verify KO Scrolling 1. Tab to the table 2. Verify a focus indicator surrounds the table 3. Verify left/right arrows scroll the table horizontally Change-Id: I32a5aee6c0c61d45851fa117a21e32aa8fae9b71 Reviewed-on: https://gerrit.instructure.com/153226 Tested-by: Jenkins Reviewed-by: Derek Bender <djbender@instructure.com> Reviewed-by: Keith T. Garner <kgarner@instructure.com> QA-Review: Adrian Packel <apackel@instructure.com> Product-Review: Sidharth Oberoi <soberoi@instructure.com>
This commit is contained in:
parent
bc35407d03
commit
c337987c93
|
@ -0,0 +1,87 @@
|
|||
/*
|
||||
* Copyright (C) 2018 - present Instructure, Inc.
|
||||
*
|
||||
* This file is part of Canvas.
|
||||
*
|
||||
* Canvas is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, version 3 of the License.
|
||||
*
|
||||
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License along
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import {bool, func, oneOf, shape} from 'prop-types'
|
||||
import Button from '@instructure/ui-buttons/lib/components/Button'
|
||||
import IconCheckMark from '@instructure/ui-icons/lib/Solid/IconCheckMark'
|
||||
import PresentationContent from '@instructure/ui-a11y/lib/components/PresentationContent'
|
||||
import Spinner from '@instructure/ui-elements/lib/components/Spinner'
|
||||
import I18n from 'i18n!assignment_grade_summary'
|
||||
|
||||
import {FAILURE, STARTED, SUCCESS} from '../assignment/AssignmentActions'
|
||||
|
||||
function readyButton(props) {
|
||||
return <Button {...props}>{I18n.t('Display to Students')}</Button>
|
||||
}
|
||||
|
||||
function startedButton(props) {
|
||||
const title = I18n.t('Displaying to Students')
|
||||
|
||||
return (
|
||||
<Button {...props} variant="light">
|
||||
<Spinner size="x-small" title={title} /> <PresentationContent>{title}</PresentationContent>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function successButton(props) {
|
||||
const title = I18n.t('Grades Visible to Students')
|
||||
|
||||
return (
|
||||
<Button {...props} variant="light">
|
||||
<IconCheckMark title={title} /> <PresentationContent>{title}</PresentationContent>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export default function DisplayToStudentsButton(props) {
|
||||
const {assignment, onClick, unmuteAssignmentStatus, ...otherProps} = props
|
||||
const unmutable = assignment.gradesPublished && assignment.muted
|
||||
const canClick = ![STARTED, SUCCESS].includes(unmuteAssignmentStatus)
|
||||
|
||||
const buttonProps = {
|
||||
...otherProps,
|
||||
'aria-readonly': !assignment.gradesPublished ? null : !assignment.muted || !canClick,
|
||||
disabled: assignment.gradesPublished ? null : true,
|
||||
onClick: unmutable && canClick ? onClick : null
|
||||
}
|
||||
|
||||
if (!assignment.muted) {
|
||||
return successButton(buttonProps)
|
||||
}
|
||||
|
||||
if (unmuteAssignmentStatus === STARTED) {
|
||||
return startedButton(buttonProps)
|
||||
}
|
||||
|
||||
return readyButton(buttonProps)
|
||||
}
|
||||
|
||||
DisplayToStudentsButton.propTypes = {
|
||||
assignment: shape({
|
||||
gradesPublished: bool.isRequired,
|
||||
muted: bool.isRequired
|
||||
}).isRequired,
|
||||
onClick: func.isRequired,
|
||||
unmuteAssignmentStatus: oneOf([FAILURE, STARTED, SUCCESS])
|
||||
}
|
||||
|
||||
DisplayToStudentsButton.defaultProps = {
|
||||
unmuteAssignmentStatus: null
|
||||
}
|
|
@ -0,0 +1,122 @@
|
|||
/*
|
||||
* Copyright (C) 2018 - present Instructure, Inc.
|
||||
*
|
||||
* This file is part of Canvas.
|
||||
*
|
||||
* Canvas is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, version 3 of the License.
|
||||
*
|
||||
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License along
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React, {Component} from 'react'
|
||||
import {func} from 'prop-types'
|
||||
|
||||
const DOWN = 40
|
||||
const LEFT = 37
|
||||
const RIGHT = 39
|
||||
const UP = 38
|
||||
|
||||
const SCROLL_OFFSET = 50
|
||||
|
||||
const style = {margin: 0, padding: 0}
|
||||
|
||||
// The following rules allow for edge cases like this component.
|
||||
|
||||
/* eslint-disable jsx-a11y/no-static-element-interactions */
|
||||
/* eslint-disable jsx-a11y/no-noninteractive-tabindex */
|
||||
|
||||
/* eslint-disable no-param-reassign */
|
||||
function bindHorizontalHandler(handlers, $el) {
|
||||
if ($el) {
|
||||
handlers[LEFT] = event => {
|
||||
if ($el.scrollLeft > 0) {
|
||||
$el.scrollLeft -= SCROLL_OFFSET
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
handlers[RIGHT] = event => {
|
||||
const {clientWidth, scrollLeft, scrollWidth} = $el
|
||||
|
||||
if (scrollWidth - (scrollLeft + clientWidth) > 0) {
|
||||
$el.scrollLeft += SCROLL_OFFSET
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
delete handlers[LEFT]
|
||||
delete handlers[RIGHT]
|
||||
}
|
||||
}
|
||||
|
||||
function bindVerticalHandler(handlers, $el) {
|
||||
if ($el) {
|
||||
handlers[UP] = event => {
|
||||
if ($el.scrollTop > 0) {
|
||||
$el.scrollTop -= SCROLL_OFFSET
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
handlers[DOWN] = event => {
|
||||
const {clientHeight, scrollTop, scrollHeight} = $el
|
||||
|
||||
if (scrollHeight - (scrollTop + clientHeight) > 0) {
|
||||
$el.scrollTop += SCROLL_OFFSET
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
delete handlers[UP]
|
||||
delete handlers[DOWN]
|
||||
}
|
||||
}
|
||||
/* eslint-enable no-param-reassign */
|
||||
|
||||
export default class FocusableView extends Component {
|
||||
static propTypes = {
|
||||
children: func.isRequired
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props)
|
||||
|
||||
this.keyHandlers = {}
|
||||
|
||||
this.bindHorizontalScroll = $ref => {
|
||||
bindHorizontalHandler(this.keyHandlers, $ref)
|
||||
}
|
||||
|
||||
this.bindVerticalScroll = $ref => {
|
||||
bindVerticalHandler(this.keyHandlers, $ref)
|
||||
}
|
||||
|
||||
this.handleKeyDown = this.handleKeyDown.bind(this)
|
||||
}
|
||||
|
||||
handleKeyDown(event) {
|
||||
const handler = this.keyHandlers[event.keyCode]
|
||||
if (handler) {
|
||||
handler(event)
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="FocusableView" onKeyDown={this.handleKeyDown} style={style} tabIndex="0">
|
||||
{this.props.children({
|
||||
horizontalScrollRef: this.bindHorizontalScroll,
|
||||
verticalScrollRef: this.bindVerticalScroll
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
|
@ -19,7 +19,8 @@
|
|||
/* eslint-disable react/no-array-index-key */
|
||||
|
||||
import React, {Component} from 'react'
|
||||
import {arrayOf, shape, string} from 'prop-types'
|
||||
import {arrayOf, func, shape, string} from 'prop-types'
|
||||
import ScreenReaderContent from '@instructure/ui-a11y/lib/components/ScreenReaderContent'
|
||||
import Text from '@instructure/ui-elements/lib/components/Text'
|
||||
import I18n from 'i18n!assignment_grade_summary'
|
||||
|
||||
|
@ -34,6 +35,7 @@ export default class Grid extends Component {
|
|||
})
|
||||
).isRequired,
|
||||
grades: shape({}).isRequired,
|
||||
horizontalScrollRef: func.isRequired,
|
||||
rows: arrayOf(
|
||||
shape({
|
||||
studentId: string.isRequired,
|
||||
|
@ -48,16 +50,25 @@ export default class Grid extends Component {
|
|||
|
||||
render() {
|
||||
return (
|
||||
<div className="GradesGrid">
|
||||
<table>
|
||||
<thead className="GradesGrid__Header">
|
||||
<tr className="GradesGrid__HeaderRow">
|
||||
<th className="GradesGrid__StudentColumnHeader" scope="col">
|
||||
<div className="GradesGrid" ref={this.props.horizontalScrollRef}>
|
||||
<table role="table">
|
||||
<caption>
|
||||
{<ScreenReaderContent>{I18n.t('Grade Selection Table')}</ScreenReaderContent>}
|
||||
</caption>
|
||||
|
||||
<thead>
|
||||
<tr className="GradesGrid__HeaderRow" role="row">
|
||||
<th className="GradesGrid__StudentColumnHeader" role="columnheader" scope="col">
|
||||
<Text>{I18n.t('Student')}</Text>
|
||||
</th>
|
||||
|
||||
{this.props.graders.map((grader, index) => (
|
||||
<th className="GradesGrid__GraderHeader" key={grader.graderId} scope="col">
|
||||
<th
|
||||
className="GradesGrid__GraderHeader"
|
||||
key={grader.graderId}
|
||||
role="columnheader"
|
||||
scope="col"
|
||||
>
|
||||
<Text>
|
||||
{grader.graderName ||
|
||||
I18n.t('Grader %{graderNumber}', {graderNumber: I18n.n(index + 1)})}
|
||||
|
@ -67,7 +78,7 @@ export default class Grid extends Component {
|
|||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody className="GradesGrid__Body">
|
||||
<tbody>
|
||||
{this.props.rows.map((row, index) => (
|
||||
<GridRow
|
||||
graders={this.props.graders}
|
||||
|
|
|
@ -51,8 +51,8 @@ export default class GridRow extends Component {
|
|||
|
||||
render() {
|
||||
return (
|
||||
<tr className={`GradesGrid__BodyRow student_${this.props.row.studentId}`}>
|
||||
<th className="GradesGrid__BodyRowHeader" scope="row">
|
||||
<tr className={`GradesGrid__BodyRow student_${this.props.row.studentId}`} role="row">
|
||||
<th className="GradesGrid__BodyRowHeader" role="rowheader" scope="row">
|
||||
<Text>{this.props.row.studentName}</Text>
|
||||
</th>
|
||||
|
||||
|
@ -60,7 +60,7 @@ export default class GridRow extends Component {
|
|||
const classNames = ['GradesGrid__ProvisionalGradeCell', `grader_${grader.graderId}`]
|
||||
|
||||
return (
|
||||
<td className={classNames.join(' ')} key={grader.graderId}>
|
||||
<td className={classNames.join(' ')} key={grader.graderId} role="cell">
|
||||
<Text>{getGrade(grader.graderId, this.props.grades)}</Text>
|
||||
</td>
|
||||
)
|
||||
|
|
|
@ -21,6 +21,7 @@ import {arrayOf, shape, string} from 'prop-types'
|
|||
import View from '@instructure/ui-layout/lib/components/View'
|
||||
import I18n from 'i18n!assignment_grade_summary'
|
||||
|
||||
import FocusableView from '../FocusableView'
|
||||
import Grid from './Grid'
|
||||
import PageNavigation from './PageNavigation'
|
||||
|
||||
|
@ -90,8 +91,17 @@ export default class GradesGrid extends Component {
|
|||
const rows = this.state.pages[this.state.currentPageIndex]
|
||||
|
||||
return (
|
||||
<div className="GradesGridContainer">
|
||||
<Grid graders={this.props.graders} grades={this.props.grades} rows={rows} />
|
||||
<div>
|
||||
<FocusableView>
|
||||
{props => (
|
||||
<Grid
|
||||
graders={this.props.graders}
|
||||
grades={this.props.grades}
|
||||
horizontalScrollRef={props.horizontalScrollRef}
|
||||
rows={rows}
|
||||
/>
|
||||
)}
|
||||
</FocusableView>
|
||||
|
||||
{this.state.pages.length > 1 && (
|
||||
<View as="div" margin="medium">
|
||||
|
|
|
@ -18,28 +18,38 @@
|
|||
|
||||
import React, {Component} from 'react'
|
||||
import {connect} from 'react-redux'
|
||||
import {bool, func, shape, string} from 'prop-types'
|
||||
import {bool, func, oneOf, shape, string} from 'prop-types'
|
||||
import Alert from '@instructure/ui-alerts/lib/components/Alert'
|
||||
import Button from '@instructure/ui-buttons/lib/components/Button'
|
||||
import Heading from '@instructure/ui-elements/lib/components/Heading'
|
||||
import Text from '@instructure/ui-elements/lib/components/Text'
|
||||
import View from '@instructure/ui-layout/lib/components/View'
|
||||
import I18n from 'i18n!assignment_grade_summary'
|
||||
|
||||
import * as AssignmentActions from '../assignment/AssignmentActions'
|
||||
import DisplayToStudentsButton from './DisplayToStudentsButton'
|
||||
import PostButton from './PostButton'
|
||||
|
||||
/* eslint-disable no-alert */
|
||||
|
||||
function enumeratedStatuses(actions) {
|
||||
return [actions.FAILURE, actions.STARTED, actions.SUCCESS]
|
||||
}
|
||||
|
||||
class Header extends Component {
|
||||
static propTypes = {
|
||||
assignment: shape({
|
||||
title: string.isRequired
|
||||
}).isRequired,
|
||||
canPublish: bool.isRequired,
|
||||
canUnmute: bool.isRequired,
|
||||
publishGrades: func.isRequired,
|
||||
publishGradesStatus: oneOf(enumeratedStatuses(AssignmentActions)),
|
||||
showNoGradersMessage: bool.isRequired,
|
||||
unmuteAssignment: func.isRequired
|
||||
unmuteAssignment: func.isRequired,
|
||||
unmuteAssignmentStatus: oneOf(enumeratedStatuses(AssignmentActions))
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
publishGradesStatus: null,
|
||||
unmuteAssignmentStatus: null
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
|
@ -92,18 +102,18 @@ class Header extends Component {
|
|||
</Heading>
|
||||
|
||||
<View as="div" margin="large 0 0 0" textAlign="end">
|
||||
<Button
|
||||
disabled={!this.props.canPublish}
|
||||
<PostButton
|
||||
gradesPublished={this.props.assignment.gradesPublished}
|
||||
margin="0 x-small 0 0"
|
||||
onClick={this.handlePublishClick}
|
||||
variant="primary"
|
||||
>
|
||||
{I18n.t('Post')}
|
||||
</Button>
|
||||
publishGradesStatus={this.props.publishGradesStatus}
|
||||
/>
|
||||
|
||||
<Button disabled={!this.props.canUnmute} onClick={this.handleUnmuteClick}>
|
||||
{I18n.t('Display to Students')}
|
||||
</Button>
|
||||
<DisplayToStudentsButton
|
||||
assignment={this.props.assignment}
|
||||
onClick={this.handleUnmuteClick}
|
||||
unmuteAssignmentStatus={this.props.unmuteAssignmentStatus}
|
||||
/>
|
||||
</View>
|
||||
</header>
|
||||
)
|
||||
|
@ -112,13 +122,12 @@ class Header extends Component {
|
|||
|
||||
function mapStateToProps(state) {
|
||||
const {assignment, publishGradesStatus, unmuteAssignmentStatus} = state.assignment
|
||||
const {gradesPublished, muted} = assignment
|
||||
|
||||
return {
|
||||
assignment,
|
||||
canPublish: !gradesPublished && publishGradesStatus !== AssignmentActions.STARTED,
|
||||
canUnmute: gradesPublished && muted && unmuteAssignmentStatus !== AssignmentActions.STARTED,
|
||||
showNoGradersMessage: !gradesPublished && state.context.graders.length === 0
|
||||
publishGradesStatus,
|
||||
showNoGradersMessage: !assignment.gradesPublished && state.context.graders.length === 0,
|
||||
unmuteAssignmentStatus
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -134,4 +143,7 @@ function mapDispatchToProps(dispatch) {
|
|||
}
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(Header)
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(Header)
|
||||
|
|
|
@ -0,0 +1,98 @@
|
|||
/*
|
||||
* Copyright (C) 2018 - present Instructure, Inc.
|
||||
*
|
||||
* This file is part of Canvas.
|
||||
*
|
||||
* Canvas is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, version 3 of the License.
|
||||
*
|
||||
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License along
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import {bool, func, oneOf} from 'prop-types'
|
||||
import Button from '@instructure/ui-buttons/lib/components/Button'
|
||||
import IconCheckMark from '@instructure/ui-icons/lib/Solid/IconCheckMark'
|
||||
import PresentationContent from '@instructure/ui-a11y/lib/components/PresentationContent'
|
||||
import Spinner from '@instructure/ui-elements/lib/components/Spinner'
|
||||
import I18n from 'i18n!assignment_grade_summary'
|
||||
|
||||
import {
|
||||
FAILURE,
|
||||
GRADES_ALREADY_PUBLISHED,
|
||||
NOT_ALL_SUBMISSIONS_HAVE_SELECTED_GRADE,
|
||||
STARTED,
|
||||
SUCCESS
|
||||
} from '../assignment/AssignmentActions'
|
||||
|
||||
function readyButton(props) {
|
||||
return (
|
||||
<Button {...props} variant="primary">
|
||||
{I18n.t('Post')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function startedButton(props) {
|
||||
const title = I18n.t('Grades Posting')
|
||||
|
||||
return (
|
||||
<Button {...props} variant="light">
|
||||
<Spinner size="x-small" title={title} /> <PresentationContent>{title}</PresentationContent>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function successButton(props) {
|
||||
const title = I18n.t('Grades Posted')
|
||||
|
||||
return (
|
||||
<Button {...props} variant="light">
|
||||
<IconCheckMark title={title} /> <PresentationContent>{title}</PresentationContent>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export default function PostButton(props) {
|
||||
const {gradesPublished, onClick, publishGradesStatus, ...otherProps} = props
|
||||
const canClick = !(gradesPublished || [STARTED, SUCCESS].includes(publishGradesStatus))
|
||||
|
||||
const buttonProps = {
|
||||
...otherProps,
|
||||
'aria-readonly': !canClick,
|
||||
onClick: canClick ? onClick : null
|
||||
}
|
||||
|
||||
if (gradesPublished) {
|
||||
return successButton(buttonProps)
|
||||
}
|
||||
|
||||
if (publishGradesStatus === STARTED) {
|
||||
return startedButton(buttonProps)
|
||||
}
|
||||
|
||||
return readyButton(buttonProps)
|
||||
}
|
||||
|
||||
PostButton.propTypes = {
|
||||
gradesPublished: bool.isRequired,
|
||||
onClick: func.isRequired,
|
||||
publishGradesStatus: oneOf([
|
||||
FAILURE,
|
||||
GRADES_ALREADY_PUBLISHED,
|
||||
NOT_ALL_SUBMISSIONS_HAVE_SELECTED_GRADE,
|
||||
STARTED,
|
||||
SUCCESS
|
||||
])
|
||||
}
|
||||
|
||||
PostButton.defaultProps = {
|
||||
publishGradesStatus: null
|
||||
}
|
|
@ -20,9 +20,9 @@
|
|||
|
||||
$headerHeight: 50px;
|
||||
$rowHeight: 50px;
|
||||
$graderColumnWidth: 116px;
|
||||
$graderColumnWidth: 156px;
|
||||
$studentColumnWidth: 200px;
|
||||
$maxGridBodyHeight: $rowHeight * 10;
|
||||
$headerBorderColor: rgb(199, 205, 209);
|
||||
|
||||
.ic-Layout-columns {
|
||||
display: flex;
|
||||
|
@ -34,59 +34,80 @@ $maxGridBodyHeight: $rowHeight * 10;
|
|||
width: 100%;
|
||||
}
|
||||
|
||||
.GradesGridContainer {
|
||||
overflow: hidden;
|
||||
/* FocusableView */
|
||||
|
||||
.FocusableView {
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -0.25rem;
|
||||
left: -0.25rem; /* stylelint-disable-line property-blacklist */
|
||||
right: -0.25rem; /* stylelint-disable-line property-blacklist */
|
||||
bottom: -0.25rem;
|
||||
border: 1px solid var(--ic-link-color);
|
||||
border-radius: $borderRadiusLarge;
|
||||
opacity: 0;
|
||||
transform: scale(0.01);
|
||||
transition: all 0.2s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
|
||||
&::before {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* End FocusableView */
|
||||
|
||||
/* GradesGrid */
|
||||
|
||||
.GradesGrid {
|
||||
background: white;
|
||||
overflow-x: auto;
|
||||
// position: relative;
|
||||
|
||||
&, * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
tr {
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
th, td {
|
||||
display: inline-block;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.GradesGrid__Header {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.GradesGrid__HeaderRow {
|
||||
border-bottom: 1px solid $headerBorderColor;
|
||||
height: $headerHeight;
|
||||
line-height: $headerHeight;
|
||||
}
|
||||
|
||||
.GradesGrid__GraderHeader {
|
||||
max-width: $graderColumnWidth;
|
||||
min-width: $graderColumnWidth;
|
||||
overflow: hidden;
|
||||
padding: 0 $ic-sp;
|
||||
text-align: center;
|
||||
text-align: direction(left);
|
||||
text-overflow: ellipsis;
|
||||
width: $graderColumnWidth;
|
||||
}
|
||||
|
||||
.GradesGrid__StudentColumnHeader {
|
||||
max-width: $studentColumnWidth;
|
||||
min-width: $studentColumnWidth;
|
||||
padding: 0 $ic-sp;
|
||||
text-align: start;
|
||||
text-align: direction(left);
|
||||
text-overflow: ellipsis;
|
||||
width: $studentColumnWidth;
|
||||
}
|
||||
|
||||
.GradesGrid__Body {
|
||||
display: block;
|
||||
max-height: $maxGridBodyHeight;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.GradesGrid__BodyRow {
|
||||
|
@ -97,13 +118,14 @@ $maxGridBodyHeight: $rowHeight * 10;
|
|||
.GradesGrid__BodyRowHeader {
|
||||
font-weight: normal;
|
||||
padding: 0 $ic-sp;
|
||||
text-align: start;
|
||||
text-align: direction(left);
|
||||
width: $studentColumnWidth;
|
||||
}
|
||||
|
||||
.GradesGrid__ProvisionalGradeCell {
|
||||
text-align: center;
|
||||
width: $graderColumnWidth;
|
||||
max-width: $graderColumnWidth;
|
||||
padding: 0 $ic-sp;
|
||||
text-align: direction(left);
|
||||
}
|
||||
|
||||
/* End GradesGrid */
|
||||
|
|
|
@ -0,0 +1,150 @@
|
|||
/*
|
||||
* Copyright (C) 2018 - present Instructure, Inc.
|
||||
*
|
||||
* This file is part of Canvas.
|
||||
*
|
||||
* Canvas is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, version 3 of the License.
|
||||
*
|
||||
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License along
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import {mount} from 'enzyme'
|
||||
|
||||
import {FAILURE, STARTED} from 'jsx/assignments/GradeSummary/assignment/AssignmentActions'
|
||||
import DisplayToStudentsButton from 'jsx/assignments/GradeSummary/components/DisplayToStudentsButton'
|
||||
|
||||
QUnit.module('GradeSummary DisplayToStudentsButton', suiteHooks => {
|
||||
let props
|
||||
let wrapper
|
||||
|
||||
suiteHooks.beforeEach(() => {
|
||||
props = {
|
||||
assignment: {
|
||||
gradesPublished: true,
|
||||
muted: true
|
||||
},
|
||||
onClick: sinon.spy(),
|
||||
unmuteAssignmentStatus: null
|
||||
}
|
||||
})
|
||||
|
||||
suiteHooks.afterEach(() => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
function mountComponent() {
|
||||
wrapper = mount(<DisplayToStudentsButton {...props} />)
|
||||
}
|
||||
|
||||
QUnit.module('when grades have not been published', contextHooks => {
|
||||
contextHooks.beforeEach(() => {
|
||||
props.assignment.gradesPublished = false
|
||||
mountComponent()
|
||||
})
|
||||
|
||||
test('is labeled with "Display to Students"', () => {
|
||||
equal(wrapper.find('button').text(), 'Display to Students')
|
||||
})
|
||||
|
||||
test('is disabled', () => {
|
||||
strictEqual(wrapper.find('button').prop('disabled'), true)
|
||||
})
|
||||
|
||||
test('does not call the onClick prop when clicked', () => {
|
||||
wrapper.find('button').simulate('click')
|
||||
strictEqual(props.onClick.callCount, 0)
|
||||
})
|
||||
})
|
||||
|
||||
QUnit.module('when grades are not yet displayed to students', contextHooks => {
|
||||
contextHooks.beforeEach(mountComponent)
|
||||
|
||||
test('is labeled with "Display to Students"', () => {
|
||||
equal(wrapper.find('button').text(), 'Display to Students')
|
||||
})
|
||||
|
||||
test('is not read-only', () => {
|
||||
notEqual(wrapper.find('button').prop('aria-readonly'), true)
|
||||
})
|
||||
|
||||
test('calls the onClick prop when clicked', () => {
|
||||
wrapper.find('button').simulate('click')
|
||||
strictEqual(props.onClick.callCount, 1)
|
||||
})
|
||||
})
|
||||
|
||||
QUnit.module('when grades are being displayed to students', contextHooks => {
|
||||
contextHooks.beforeEach(() => {
|
||||
props.unmuteAssignmentStatus = STARTED
|
||||
mountComponent()
|
||||
})
|
||||
|
||||
test('is labeled with "Displaying to Students"', () => {
|
||||
// The Spinner in the button duplicates the label. Assert that the label
|
||||
// includes the expected text, but is not exactly equal.
|
||||
const label = wrapper.find('button').text()
|
||||
ok(label.match(/Displaying to Students/))
|
||||
})
|
||||
|
||||
test('is read-only', () => {
|
||||
strictEqual(wrapper.find('button').prop('aria-readonly'), true)
|
||||
})
|
||||
|
||||
test('does not call the onClick prop when clicked', () => {
|
||||
wrapper.find('button').simulate('click')
|
||||
strictEqual(props.onClick.callCount, 0)
|
||||
})
|
||||
})
|
||||
|
||||
QUnit.module('when grades are visible to students', contextHooks => {
|
||||
contextHooks.beforeEach(() => {
|
||||
props.assignment.muted = false
|
||||
mountComponent()
|
||||
})
|
||||
|
||||
test('is labeled with "Grades Visible to Students"', () => {
|
||||
// The Icon in the button duplicates the label. Assert that the label
|
||||
// includes the expected text, but is not exactly equal.
|
||||
const label = wrapper.find('button').text()
|
||||
ok(label.match(/Grades Visible to Students/))
|
||||
})
|
||||
|
||||
test('is read-only', () => {
|
||||
strictEqual(wrapper.find('button').prop('aria-readonly'), true)
|
||||
})
|
||||
|
||||
test('does not call the onClick prop when clicked', () => {
|
||||
wrapper.find('button').simulate('click')
|
||||
strictEqual(props.onClick.callCount, 0)
|
||||
})
|
||||
})
|
||||
|
||||
QUnit.module('when displaying to students failed', contextHooks => {
|
||||
contextHooks.beforeEach(() => {
|
||||
props.unmuteAssignmentStatus = FAILURE
|
||||
mountComponent()
|
||||
})
|
||||
|
||||
test('is labeled with "Display to Students"', () => {
|
||||
equal(wrapper.find('button').text(), 'Display to Students')
|
||||
})
|
||||
|
||||
test('is not read-only', () => {
|
||||
notEqual(wrapper.find('button').prop('aria-readonly'), true)
|
||||
})
|
||||
|
||||
test('calls the onClick prop when clicked', () => {
|
||||
wrapper.find('button').simulate('click')
|
||||
strictEqual(props.onClick.callCount, 1)
|
||||
})
|
||||
})
|
||||
})
|
|
@ -0,0 +1,420 @@
|
|||
/*
|
||||
* Copyright (C) 2018 - present Instructure, Inc.
|
||||
*
|
||||
* This file is part of Canvas.
|
||||
*
|
||||
* Canvas is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, version 3 of the License.
|
||||
*
|
||||
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License along
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import {mount} from 'enzyme'
|
||||
|
||||
import FocusableView from 'jsx/assignments/GradeSummary/components/FocusableView'
|
||||
|
||||
// This rule does not apply for these specs.
|
||||
/* eslint-disable react/prop-types */
|
||||
|
||||
QUnit.module('GradeSummary FocusableView', suiteHooks => {
|
||||
let $container
|
||||
let renderChildren
|
||||
let wrapper
|
||||
|
||||
suiteHooks.beforeEach(() => {
|
||||
$container = document.createElement('div')
|
||||
document.body.appendChild($container)
|
||||
})
|
||||
|
||||
suiteHooks.afterEach(() => {
|
||||
wrapper.unmount()
|
||||
$container.remove()
|
||||
})
|
||||
|
||||
function mountComponent() {
|
||||
wrapper = mount(<FocusableView>{renderChildren}</FocusableView>, {attachTo: $container})
|
||||
}
|
||||
|
||||
const keyCodeMap = {
|
||||
down: 40,
|
||||
left: 37,
|
||||
right: 39,
|
||||
up: 38
|
||||
}
|
||||
|
||||
function scroll(direction) {
|
||||
const event = {
|
||||
keyCode: keyCodeMap[direction],
|
||||
preventDefault: sinon.spy()
|
||||
}
|
||||
wrapper.find(FocusableView).simulate('keyDown', event)
|
||||
return event
|
||||
}
|
||||
|
||||
function horizontalScrollTarget() {
|
||||
return wrapper.find('.HorizontalTarget').getDOMNode()
|
||||
}
|
||||
|
||||
function verticalScrollTarget() {
|
||||
return wrapper.find('.VerticalTarget').getDOMNode()
|
||||
}
|
||||
|
||||
test('is focusable', () => {
|
||||
renderChildren = () => <span>Example</span>
|
||||
mountComponent()
|
||||
const node = wrapper.find(FocusableView).getDOMNode()
|
||||
node.focus()
|
||||
strictEqual(node, document.activeElement)
|
||||
})
|
||||
|
||||
test('is focusable via tabbing', () => {
|
||||
renderChildren = () => <span>Example</span>
|
||||
mountComponent()
|
||||
const node = wrapper.find(FocusableView).getDOMNode()
|
||||
strictEqual(node.getAttribute('tabindex'), '0')
|
||||
})
|
||||
|
||||
QUnit.module('when given a horizontal scroll reference', hooks => {
|
||||
const scrollTargetStyle = {height: '100px', overflow: 'auto', width: '100px'}
|
||||
const scrollContentStyle = {height: '500px', width: '500px'}
|
||||
|
||||
let maxScrollLeft
|
||||
|
||||
hooks.beforeEach(() => {
|
||||
renderChildren = props => (
|
||||
<div className="HorizontalTarget" ref={props.horizontalScrollRef} style={scrollTargetStyle}>
|
||||
<div style={scrollContentStyle} />
|
||||
</div>
|
||||
)
|
||||
mountComponent()
|
||||
|
||||
if (maxScrollLeft == null) {
|
||||
maxScrollLeft = 500 - horizontalScrollTarget().clientWidth
|
||||
}
|
||||
})
|
||||
|
||||
test('scrolls right on the scroll target', () => {
|
||||
scroll('right')
|
||||
strictEqual(horizontalScrollTarget().scrollLeft, 50)
|
||||
})
|
||||
|
||||
test('prevents default event behavior when scrolling right', () => {
|
||||
const event = scroll('right')
|
||||
strictEqual(event.preventDefault.callCount, 1)
|
||||
})
|
||||
|
||||
test('stops scrolling right at the rightmost limit', () => {
|
||||
horizontalScrollTarget().scrollLeft = maxScrollLeft - 10
|
||||
scroll('right')
|
||||
strictEqual(horizontalScrollTarget().scrollLeft, maxScrollLeft)
|
||||
})
|
||||
|
||||
test('prevents default event behavior when scrolling to the rightmost limit', () => {
|
||||
horizontalScrollTarget().scrollLeft = maxScrollLeft - 10
|
||||
const event = scroll('right')
|
||||
strictEqual(event.preventDefault.callCount, 1)
|
||||
})
|
||||
|
||||
test('does not scroll beyond the rightmost limit', () => {
|
||||
horizontalScrollTarget().scrollLeft = maxScrollLeft
|
||||
scroll('right')
|
||||
strictEqual(horizontalScrollTarget().scrollLeft, maxScrollLeft)
|
||||
})
|
||||
|
||||
test('does not prevent default event behavior when stopped at the rightmost limit', () => {
|
||||
horizontalScrollTarget().scrollLeft = maxScrollLeft
|
||||
const event = scroll('right')
|
||||
strictEqual(event.preventDefault.callCount, 0)
|
||||
})
|
||||
|
||||
test('scrolls left on the scroll target', () => {
|
||||
horizontalScrollTarget().scrollLeft = 250
|
||||
scroll('left')
|
||||
strictEqual(horizontalScrollTarget().scrollLeft, 200)
|
||||
})
|
||||
|
||||
test('prevents default event behavior when scrolling left', () => {
|
||||
horizontalScrollTarget().scrollLeft = 250
|
||||
const event = scroll('left')
|
||||
strictEqual(event.preventDefault.callCount, 1)
|
||||
})
|
||||
|
||||
test('stops scrolling left at the leftmost limit', () => {
|
||||
horizontalScrollTarget().scrollLeft = 10
|
||||
scroll('left')
|
||||
strictEqual(horizontalScrollTarget().scrollLeft, 0)
|
||||
})
|
||||
|
||||
test('prevents default event behavior when scrolling to the leftmost limit', () => {
|
||||
horizontalScrollTarget().scrollLeft = 10
|
||||
const event = scroll('left')
|
||||
strictEqual(event.preventDefault.callCount, 1)
|
||||
})
|
||||
|
||||
test('does not scroll beyond the leftmost limit', () => {
|
||||
horizontalScrollTarget().scrollLeft = 0
|
||||
scroll('left')
|
||||
strictEqual(horizontalScrollTarget().scrollLeft, 0)
|
||||
})
|
||||
|
||||
test('does not prevent default event behavior when stopped at the leftmost limit', () => {
|
||||
horizontalScrollTarget().scrollLeft = 0
|
||||
const event = scroll('left')
|
||||
strictEqual(event.preventDefault.callCount, 0)
|
||||
})
|
||||
|
||||
test('does not scroll down', () => {
|
||||
scroll('down')
|
||||
strictEqual(horizontalScrollTarget().scrollTop, 0)
|
||||
})
|
||||
|
||||
test('does not prevent default event behavior for down arrow', () => {
|
||||
horizontalScrollTarget().scrollLeft = 0
|
||||
const event = scroll('down')
|
||||
strictEqual(event.preventDefault.callCount, 0)
|
||||
})
|
||||
|
||||
test('does not scroll up', () => {
|
||||
horizontalScrollTarget().scrollTop = 100
|
||||
scroll('up')
|
||||
strictEqual(horizontalScrollTarget().scrollTop, 100)
|
||||
})
|
||||
|
||||
test('does not prevent default event behavior for up arrow', () => {
|
||||
horizontalScrollTarget().scrollTop = 100
|
||||
const event = scroll('up')
|
||||
strictEqual(event.preventDefault.callCount, 0)
|
||||
})
|
||||
})
|
||||
|
||||
QUnit.module('when given a vertical scroll reference', hooks => {
|
||||
const scrollTargetStyle = {height: '100px', overflow: 'auto', width: '100px'}
|
||||
const scrollContentStyle = {height: '500px', width: '500px'}
|
||||
|
||||
let maxScrollTop
|
||||
|
||||
hooks.beforeEach(() => {
|
||||
renderChildren = props => (
|
||||
<div className="VerticalTarget" ref={props.verticalScrollRef} style={scrollTargetStyle}>
|
||||
<div style={scrollContentStyle} />
|
||||
</div>
|
||||
)
|
||||
mountComponent()
|
||||
|
||||
if (maxScrollTop == null) {
|
||||
maxScrollTop = 500 - verticalScrollTarget().clientHeight
|
||||
}
|
||||
})
|
||||
|
||||
test('scrolls down on the scroll target', () => {
|
||||
scroll('down')
|
||||
strictEqual(verticalScrollTarget().scrollTop, 50)
|
||||
})
|
||||
|
||||
test('prevents default event behavior when scrolling down', () => {
|
||||
const event = scroll('down')
|
||||
strictEqual(event.preventDefault.callCount, 1)
|
||||
})
|
||||
|
||||
test('stops scrolling down at the bottommost limit', () => {
|
||||
verticalScrollTarget().scrollTop = maxScrollTop - 10
|
||||
scroll('down')
|
||||
strictEqual(verticalScrollTarget().scrollTop, maxScrollTop)
|
||||
})
|
||||
|
||||
test('prevents default event behavior when scrolling to the bottommost limit', () => {
|
||||
verticalScrollTarget().scrollTop = maxScrollTop - 10
|
||||
const event = scroll('down')
|
||||
strictEqual(event.preventDefault.callCount, 1)
|
||||
})
|
||||
|
||||
test('does not scroll beyond the bottommost limit', () => {
|
||||
verticalScrollTarget().scrollTop = maxScrollTop
|
||||
scroll('down')
|
||||
strictEqual(verticalScrollTarget().scrollTop, maxScrollTop)
|
||||
})
|
||||
|
||||
test('does not prevent default event behavior when stopped at the bottommost limit', () => {
|
||||
verticalScrollTarget().scrollTop = maxScrollTop
|
||||
const event = scroll('down')
|
||||
strictEqual(event.preventDefault.callCount, 0)
|
||||
})
|
||||
|
||||
test('scrolls up on the scroll target', () => {
|
||||
verticalScrollTarget().scrollTop = 250
|
||||
scroll('up')
|
||||
strictEqual(verticalScrollTarget().scrollTop, 200)
|
||||
})
|
||||
|
||||
test('prevents default event behavior when scrolling up', () => {
|
||||
verticalScrollTarget().scrollTop = 250
|
||||
const event = scroll('up')
|
||||
strictEqual(event.preventDefault.callCount, 1)
|
||||
})
|
||||
|
||||
test('stops scrolling up at the topmost limit', () => {
|
||||
verticalScrollTarget().scrollTop = 10
|
||||
scroll('up')
|
||||
strictEqual(verticalScrollTarget().scrollTop, 0)
|
||||
})
|
||||
|
||||
test('prevents default event behavior when scrolling to the topmost limit', () => {
|
||||
verticalScrollTarget().scrollTop = 10
|
||||
const event = scroll('up')
|
||||
strictEqual(event.preventDefault.callCount, 1)
|
||||
})
|
||||
|
||||
test('does not scroll beyond the topmost limit', () => {
|
||||
verticalScrollTarget().scrollTop = 0
|
||||
scroll('up')
|
||||
strictEqual(verticalScrollTarget().scrollTop, 0)
|
||||
})
|
||||
|
||||
test('does not prevent default event behavior when stopped at the topmost limit', () => {
|
||||
verticalScrollTarget().scrollTop = 0
|
||||
const event = scroll('up')
|
||||
strictEqual(event.preventDefault.callCount, 0)
|
||||
})
|
||||
|
||||
test('does not scroll right', () => {
|
||||
scroll('right')
|
||||
strictEqual(verticalScrollTarget().scrollLeft, 0)
|
||||
})
|
||||
|
||||
test('does not prevent default event behavior for right arrow', () => {
|
||||
const event = scroll('right')
|
||||
strictEqual(event.preventDefault.callCount, 0)
|
||||
})
|
||||
|
||||
test('does not scroll left', () => {
|
||||
verticalScrollTarget().scrollLeft = 100
|
||||
scroll('left')
|
||||
strictEqual(verticalScrollTarget().scrollLeft, 100)
|
||||
})
|
||||
|
||||
test('does not prevent default event behavior for left arrow', () => {
|
||||
verticalScrollTarget().scrollLeft = 100
|
||||
const event = scroll('left')
|
||||
strictEqual(event.preventDefault.callCount, 0)
|
||||
})
|
||||
})
|
||||
|
||||
QUnit.module('when given the same horizontal and vertical scroll reference', hooks => {
|
||||
const scrollTargetStyle = {height: '200px', overflow: 'auto', width: '200px'}
|
||||
const scrollContentStyle = {height: '500px', width: '500px'}
|
||||
|
||||
hooks.beforeEach(() => {
|
||||
renderChildren = props => {
|
||||
const bindRefs = ref => {
|
||||
props.horizontalScrollRef(ref)
|
||||
props.verticalScrollRef(ref)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="HorizontalTarget VerticalTarget" ref={bindRefs} style={scrollTargetStyle}>
|
||||
<div style={scrollContentStyle} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
mountComponent()
|
||||
})
|
||||
|
||||
test('scrolls right on the scroll target', () => {
|
||||
scroll('right')
|
||||
strictEqual(horizontalScrollTarget().scrollLeft, 50)
|
||||
})
|
||||
|
||||
test('scrolls left on the scroll target', () => {
|
||||
horizontalScrollTarget().scrollLeft = 250
|
||||
scroll('left')
|
||||
strictEqual(horizontalScrollTarget().scrollLeft, 200)
|
||||
})
|
||||
|
||||
test('scrolls down on the scroll target', () => {
|
||||
scroll('down')
|
||||
strictEqual(verticalScrollTarget().scrollTop, 50)
|
||||
})
|
||||
|
||||
test('scrolls up on the scroll target', () => {
|
||||
verticalScrollTarget().scrollTop = 250
|
||||
scroll('up')
|
||||
strictEqual(verticalScrollTarget().scrollTop, 200)
|
||||
})
|
||||
})
|
||||
|
||||
QUnit.module('when given different horizontal and vertical scroll references', hooks => {
|
||||
const scrollTargetStyle = {height: '200px', overflow: 'auto', width: '200px'}
|
||||
const scrollContentStyle = {height: '500px', width: '500px'}
|
||||
|
||||
hooks.beforeEach(() => {
|
||||
renderChildren = props => (
|
||||
<div>
|
||||
<div className="VerticalTarget" ref={props.verticalScrollRef} style={scrollTargetStyle}>
|
||||
<div style={scrollContentStyle} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="HorizontalTarget"
|
||||
ref={props.horizontalScrollRef}
|
||||
style={scrollTargetStyle}
|
||||
>
|
||||
<div style={scrollContentStyle} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
mountComponent()
|
||||
})
|
||||
|
||||
test('scrolls right on the horizontal scroll target', () => {
|
||||
scroll('right')
|
||||
strictEqual(horizontalScrollTarget().scrollLeft, 50)
|
||||
})
|
||||
|
||||
test('does not scroll the vertical target right', () => {
|
||||
scroll('right')
|
||||
strictEqual(verticalScrollTarget().scrollLeft, 0)
|
||||
})
|
||||
|
||||
test('scrolls left on the horizontal scroll target', () => {
|
||||
horizontalScrollTarget().scrollLeft = 250
|
||||
scroll('left')
|
||||
strictEqual(horizontalScrollTarget().scrollLeft, 200)
|
||||
})
|
||||
|
||||
test('does not scroll the vertical target left', () => {
|
||||
verticalScrollTarget().scrollLeft = 100
|
||||
scroll('left')
|
||||
strictEqual(verticalScrollTarget().scrollLeft, 100)
|
||||
})
|
||||
|
||||
test('scrolls down on the vertical scroll target', () => {
|
||||
scroll('down')
|
||||
strictEqual(verticalScrollTarget().scrollTop, 50)
|
||||
})
|
||||
|
||||
test('does not scroll the horizontal target down', () => {
|
||||
scroll('down')
|
||||
strictEqual(horizontalScrollTarget().scrollTop, 0)
|
||||
})
|
||||
|
||||
test('scrolls up on the vertical scroll target', () => {
|
||||
verticalScrollTarget().scrollTop = 250
|
||||
scroll('up')
|
||||
strictEqual(verticalScrollTarget().scrollTop, 200)
|
||||
})
|
||||
|
||||
test('does not scroll the horizontal target up', () => {
|
||||
horizontalScrollTarget().scrollTop = 250
|
||||
scroll('up')
|
||||
strictEqual(horizontalScrollTarget().scrollTop, 250)
|
||||
})
|
||||
})
|
||||
})
|
|
@ -31,6 +31,7 @@ QUnit.module('GradeSummary Grid', suiteHooks => {
|
|||
{graderId: '1101', graderName: 'Miss Frizzle'},
|
||||
{graderId: '1102', graderName: 'Mr. Keating'}
|
||||
],
|
||||
|
||||
grades: {
|
||||
1111: {
|
||||
1101: {
|
||||
|
@ -71,6 +72,8 @@ QUnit.module('GradeSummary Grid', suiteHooks => {
|
|||
}
|
||||
}
|
||||
},
|
||||
|
||||
horizontalScrollRef: sinon.spy(),
|
||||
rows: [
|
||||
{studentId: '1111', studentName: 'Adam Jones'},
|
||||
{studentId: '1112', studentName: 'Betty Ford'},
|
||||
|
@ -130,4 +133,10 @@ QUnit.module('GradeSummary Grid', suiteHooks => {
|
|||
const gridRow = wrapper.find('GridRow').at(1)
|
||||
strictEqual(gridRow.prop('row'), props.rows[1])
|
||||
})
|
||||
|
||||
test('binds the GradesGrid container using the horizontalScrollRef prop', () => {
|
||||
mountComponent()
|
||||
const [ref] = props.horizontalScrollRef.lastCall.args
|
||||
strictEqual(ref, wrapper.find('.GradesGrid').get(0))
|
||||
})
|
||||
})
|
||||
|
|
|
@ -98,61 +98,36 @@ QUnit.module('GradeSummary Header', suiteHooks => {
|
|||
window.confirm.restore()
|
||||
})
|
||||
|
||||
function postButton() {
|
||||
return wrapper.find('button').filterWhere(button => button.text() === 'Post')
|
||||
}
|
||||
test('receives the assignment gradesPublished property as a prop', () => {
|
||||
mountComponent()
|
||||
strictEqual(wrapper.find('PostButton').prop('gradesPublished'), false)
|
||||
})
|
||||
|
||||
test('receives the unmuteAssignmentStatus as a prop', () => {
|
||||
mountComponent()
|
||||
store.dispatch(AssignmentActions.setPublishGradesStatus(AssignmentActions.STARTED))
|
||||
const button = wrapper.find('PostButton')
|
||||
equal(button.prop('publishGradesStatus'), AssignmentActions.STARTED)
|
||||
})
|
||||
|
||||
test('displays a confirmation dialog when clicked', () => {
|
||||
mountComponent()
|
||||
postButton().simulate('click')
|
||||
wrapper.find('PostButton').simulate('click')
|
||||
strictEqual(window.confirm.callCount, 1)
|
||||
})
|
||||
|
||||
test('publishes grades when dialog is confirmed', () => {
|
||||
mountComponent()
|
||||
postButton().simulate('click')
|
||||
wrapper.find('PostButton').simulate('click')
|
||||
equal(store.getState().assignment.publishGradesStatus, AssignmentActions.STARTED)
|
||||
})
|
||||
|
||||
test('does not publish grades when dialog is dismissed', () => {
|
||||
window.confirm.returns(false)
|
||||
mountComponent()
|
||||
postButton().simulate('click')
|
||||
wrapper.find('PostButton').simulate('click')
|
||||
strictEqual(store.getState().assignment.publishGradesStatus, null)
|
||||
})
|
||||
|
||||
test('is disabled when grades are being published', () => {
|
||||
mountComponent()
|
||||
store.dispatch(AssignmentActions.setPublishGradesStatus(AssignmentActions.STARTED))
|
||||
strictEqual(postButton().prop('disabled'), true)
|
||||
})
|
||||
|
||||
test('performs no action upon click when grades are being published', () => {
|
||||
mountComponent()
|
||||
store.dispatch(AssignmentActions.setPublishGradesStatus(AssignmentActions.STARTED))
|
||||
postButton().simulate('click')
|
||||
strictEqual(window.confirm.callCount, 0)
|
||||
})
|
||||
|
||||
test('is disabled when grades were already published', () => {
|
||||
storeEnv.assignment.gradesPublished = true
|
||||
mountComponent()
|
||||
strictEqual(postButton().prop('disabled'), true)
|
||||
})
|
||||
|
||||
test('performs no action upon click when grades were already published', () => {
|
||||
storeEnv.assignment.gradesPublished = true
|
||||
mountComponent()
|
||||
postButton().simulate('click')
|
||||
strictEqual(window.confirm.callCount, 0)
|
||||
})
|
||||
|
||||
test('is enabled when grade publishing failed', () => {
|
||||
mountComponent()
|
||||
store.dispatch(AssignmentActions.setPublishGradesStatus(AssignmentActions.STARTED))
|
||||
store.dispatch(AssignmentActions.setPublishGradesStatus(AssignmentActions.FAILURE))
|
||||
notEqual(postButton().prop('disabled'), true)
|
||||
})
|
||||
})
|
||||
|
||||
QUnit.module('"Display to Students" button', hooks => {
|
||||
|
@ -169,73 +144,36 @@ QUnit.module('GradeSummary Header', suiteHooks => {
|
|||
window.confirm.restore()
|
||||
})
|
||||
|
||||
function unmuteButton() {
|
||||
return wrapper.find('button').filterWhere(button => button.text() === 'Display to Students')
|
||||
}
|
||||
test('receives the assignment as a prop', () => {
|
||||
mountComponent()
|
||||
const button = wrapper.find('DisplayToStudentsButton')
|
||||
deepEqual(button.prop('assignment'), storeEnv.assignment)
|
||||
})
|
||||
|
||||
test('receives the unmuteAssignmentStatus as a prop', () => {
|
||||
mountComponent()
|
||||
store.dispatch(AssignmentActions.setUnmuteAssignmentStatus(AssignmentActions.STARTED))
|
||||
const button = wrapper.find('DisplayToStudentsButton')
|
||||
equal(button.prop('unmuteAssignmentStatus'), AssignmentActions.STARTED)
|
||||
})
|
||||
|
||||
test('displays a confirmation dialog when clicked', () => {
|
||||
mountComponent()
|
||||
unmuteButton().simulate('click')
|
||||
wrapper.find('DisplayToStudentsButton').simulate('click')
|
||||
strictEqual(window.confirm.callCount, 1)
|
||||
})
|
||||
|
||||
test('unmutes the assignment when dialog is confirmed', () => {
|
||||
mountComponent()
|
||||
unmuteButton().simulate('click')
|
||||
wrapper.find('DisplayToStudentsButton').simulate('click')
|
||||
equal(store.getState().assignment.unmuteAssignmentStatus, AssignmentActions.STARTED)
|
||||
})
|
||||
|
||||
test('does not unmute the assignment when dialog is dismissed', () => {
|
||||
window.confirm.returns(false)
|
||||
mountComponent()
|
||||
unmuteButton().simulate('click')
|
||||
wrapper.find('DisplayToStudentsButton').simulate('click')
|
||||
strictEqual(store.getState().assignment.unmuteAssignmentStatus, null)
|
||||
})
|
||||
|
||||
test('is disabled when the assignment is being unmuted', () => {
|
||||
mountComponent()
|
||||
store.dispatch(AssignmentActions.setUnmuteAssignmentStatus(AssignmentActions.STARTED))
|
||||
strictEqual(unmuteButton().prop('disabled'), true)
|
||||
})
|
||||
|
||||
test('performs no action upon click when the assignment is being unmuted', () => {
|
||||
mountComponent()
|
||||
store.dispatch(AssignmentActions.setUnmuteAssignmentStatus(AssignmentActions.STARTED))
|
||||
unmuteButton().simulate('click')
|
||||
strictEqual(window.confirm.callCount, 0)
|
||||
})
|
||||
|
||||
test('is disabled when the assignment is not muted', () => {
|
||||
storeEnv.assignment.muted = false
|
||||
mountComponent()
|
||||
strictEqual(unmuteButton().prop('disabled'), true)
|
||||
})
|
||||
|
||||
test('performs no action upon click when assignment is not muted', () => {
|
||||
storeEnv.assignment.muted = false
|
||||
mountComponent()
|
||||
unmuteButton().simulate('click')
|
||||
strictEqual(window.confirm.callCount, 0)
|
||||
})
|
||||
|
||||
test('is disabled when grades have not been published', () => {
|
||||
storeEnv.assignment.muted = false
|
||||
mountComponent()
|
||||
strictEqual(unmuteButton().prop('disabled'), true)
|
||||
})
|
||||
|
||||
test('performs no action upon click when grades have not been published', () => {
|
||||
storeEnv.assignment.muted = false
|
||||
mountComponent()
|
||||
unmuteButton().simulate('click')
|
||||
strictEqual(window.confirm.callCount, 0)
|
||||
})
|
||||
|
||||
test('is enabled when assignment unmuting failed', () => {
|
||||
mountComponent()
|
||||
store.dispatch(AssignmentActions.setUnmuteAssignmentStatus(AssignmentActions.STARTED))
|
||||
store.dispatch(AssignmentActions.setUnmuteAssignmentStatus(AssignmentActions.FAILURE))
|
||||
notEqual(unmuteButton().prop('disabled'), true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -0,0 +1,151 @@
|
|||
/*
|
||||
* Copyright (C) 2018 - present Instructure, Inc.
|
||||
*
|
||||
* This file is part of Canvas.
|
||||
*
|
||||
* Canvas is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, version 3 of the License.
|
||||
*
|
||||
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License along
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import {mount} from 'enzyme'
|
||||
|
||||
import {
|
||||
FAILURE,
|
||||
NOT_ALL_SUBMISSIONS_HAVE_SELECTED_GRADE,
|
||||
STARTED
|
||||
} from 'jsx/assignments/GradeSummary/assignment/AssignmentActions'
|
||||
import PostButton from 'jsx/assignments/GradeSummary/components/PostButton'
|
||||
|
||||
QUnit.module('GradeSummary PostButton', suiteHooks => {
|
||||
let props
|
||||
let wrapper
|
||||
|
||||
suiteHooks.beforeEach(() => {
|
||||
props = {
|
||||
gradesPublished: false,
|
||||
onClick: sinon.spy(),
|
||||
publishGradesStatus: null
|
||||
}
|
||||
})
|
||||
|
||||
suiteHooks.afterEach(() => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
function mountComponent() {
|
||||
wrapper = mount(<PostButton {...props} />)
|
||||
}
|
||||
|
||||
QUnit.module('when grades have not been published', contextHooks => {
|
||||
contextHooks.beforeEach(mountComponent)
|
||||
|
||||
test('is labeled with "Post"', () => {
|
||||
equal(wrapper.find('button').text(), 'Post')
|
||||
})
|
||||
|
||||
test('is not read-only', () => {
|
||||
notEqual(wrapper.find('button').prop('aria-readonly'), true)
|
||||
})
|
||||
|
||||
test('calls the onClick prop when clicked', () => {
|
||||
wrapper.find('button').simulate('click')
|
||||
strictEqual(props.onClick.callCount, 1)
|
||||
})
|
||||
})
|
||||
|
||||
QUnit.module('when grades are being published', contextHooks => {
|
||||
contextHooks.beforeEach(() => {
|
||||
props.publishGradesStatus = STARTED
|
||||
mountComponent()
|
||||
})
|
||||
|
||||
test('is labeled with "Posting Grades"', () => {
|
||||
// The Spinner in the button duplicates the label. Assert that the label
|
||||
// includes the expected text, but is not exactly equal.
|
||||
const label = wrapper.find('button').text()
|
||||
ok(label.match(/Posting Grades/))
|
||||
})
|
||||
|
||||
test('is read-only', () => {
|
||||
strictEqual(wrapper.find('button').prop('aria-readonly'), true)
|
||||
})
|
||||
|
||||
test('does not call the onClick prop when clicked', () => {
|
||||
wrapper.find('button').simulate('click')
|
||||
strictEqual(props.onClick.callCount, 0)
|
||||
})
|
||||
})
|
||||
|
||||
QUnit.module('when grades have been published', contextHooks => {
|
||||
contextHooks.beforeEach(() => {
|
||||
props.gradesPublished = true
|
||||
mountComponent()
|
||||
})
|
||||
|
||||
test('is labeled with "Grades Posted"', () => {
|
||||
// The Icon in the button duplicates the label. Assert that the label
|
||||
// includes the expected text, but is not exactly equal.
|
||||
const label = wrapper.find('button').text()
|
||||
ok(label.match(/Grades Posted/))
|
||||
})
|
||||
|
||||
test('is read-only', () => {
|
||||
strictEqual(wrapper.find('button').prop('aria-readonly'), true)
|
||||
})
|
||||
|
||||
test('does not call the onClick prop when clicked', () => {
|
||||
wrapper.find('button').simulate('click')
|
||||
strictEqual(props.onClick.callCount, 0)
|
||||
})
|
||||
})
|
||||
|
||||
QUnit.module('when grade publishing failed', contextHooks => {
|
||||
contextHooks.beforeEach(() => {
|
||||
props.publishGradesStatus = FAILURE
|
||||
mountComponent()
|
||||
})
|
||||
|
||||
test('is labeled with "Post"', () => {
|
||||
equal(wrapper.find('button').text(), 'Post')
|
||||
})
|
||||
|
||||
test('is not read-only', () => {
|
||||
notEqual(wrapper.find('button').prop('aria-readonly'), true)
|
||||
})
|
||||
|
||||
test('calls the onClick prop when clicked', () => {
|
||||
wrapper.find('button').simulate('click')
|
||||
strictEqual(props.onClick.callCount, 1)
|
||||
})
|
||||
})
|
||||
|
||||
QUnit.module('when grade publishing failed for missing grade selections', contextHooks => {
|
||||
contextHooks.beforeEach(() => {
|
||||
props.publishGradesStatus = NOT_ALL_SUBMISSIONS_HAVE_SELECTED_GRADE
|
||||
mountComponent()
|
||||
})
|
||||
|
||||
test('is labeled with "Post"', () => {
|
||||
equal(wrapper.find('button').text(), 'Post')
|
||||
})
|
||||
|
||||
test('is not read-only', () => {
|
||||
notEqual(wrapper.find('button').prop('aria-readonly'), true)
|
||||
})
|
||||
|
||||
test('calls the onClick prop when clicked', () => {
|
||||
wrapper.find('button').simulate('click')
|
||||
strictEqual(props.onClick.callCount, 1)
|
||||
})
|
||||
})
|
||||
})
|
Loading…
Reference in New Issue