A2 edit date-times

fixes: ADMIN-2488

- Enables editing of override date-times
- Because the dates required cross-field validation, changes were
  made to how validation failure messages are handled.

TESTING NOTES:
  - The due/available/until date editor has issues
    - is completely broken in firefox
    - cannot close with ESC in firefox or Edge
    - VO and NVDA get stuck on the edit button after the
      editor opens
    see ADMIN-2568
  - only test the date editing. anything below that are just
    placeholders.

test plan:
  - edit an assignment (you no longer need #edit in the URL)
  - edit due, available, and until dates
  > expect validation to require available < due < until dates
  > expect the error message to appear in the popup when editing
    and to be shown below the 3 dates when no dates are being edited
    (note: you may get a slightly different message)
  - try to navigate away from the page
  > if you have changed anything but not saved, expect a confirmation
    popup
  - click Cancel
  > expect the date values (and anything else changed) to revert to
    their original values and the footer goes away
  - edit stuff
  - click Save
  > expect the Everyone Else dates to be saved, but not override.
    (that's another ticket)
also
  - if the assignment is assigned to everyone in the class
    via sections and/or individual students, there will be no
    "Everyone Else"
  - if the assignment is simply assigned to everyone, there will
    be a single "Everyone" override
  - if the assignment is assigned to some students via sections or
    individually, there will be an "Everyone Else" override

Change-Id: Ib22c747aef7b3541501e0a8fecd6e519147a62b7
Reviewed-on: https://gerrit.instructure.com/184622
Tested-by: Jenkins
Reviewed-by: Carl Kibler <ckibler@instructure.com>
QA-Review: Daniel Sasaki <dsasaki@instructure.com>
Product-Review: Ed Schiebel <eschiebel@instructure.com>
This commit is contained in:
Ed Schiebel 2019-03-08 14:08:56 -05:00
parent bedff1a221
commit 069b1ecaf5
38 changed files with 1853 additions and 424 deletions

View File

@ -0,0 +1,176 @@
/*
* Copyright (C) 2019 - 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 I18n from 'i18n!assignments_2'
export default class AssignmentFieldValidator {
messages = {}
// beause isNan is not the same as Number.isNaN
/* eslint-disable no-restricted-globals */
isPointsValid = value => {
const strValue = `${value}`
if (!strValue) {
this.messages.pointsPossible = I18n.t('Points is required')
return false // it's required
}
if (isNaN(strValue)) {
this.messages.pointsPossible = I18n.t('Points must be a number >= 0')
return false // must be a number
}
if (parseFloat(strValue) < 0) {
// must be non-negative
this.messages.pointsPossible = I18n.t('Points must >= 0')
return false
}
delete this.messages.pointsPossible
return true
}
/* eslint-enable no-restricted-globals */
isNameValid = value => {
if (!value || value.trim().length === 0) {
this.messages.name = I18n.t('Assignment name is required')
return false
}
delete this.messages.name
return true
}
// the raw date-time value is invalid
// set an appropriate error message
getInvalidDateTimeMessage = ({rawDateValue, rawTimeValue}) => {
let msg
if (rawDateValue) {
msg = I18n.t('The date is not valid.', {value: rawDateValue})
} else if (rawTimeValue) {
msg = I18n.t('You must provide a date with a time.')
} else {
msg = I18n.t('Invalid date or time')
}
return msg
}
// A note on the date cross-field validators.
// Though we want error messages specific to the field being edited,
// we only want one at a time to be logged.
// So if date A fails in comparison to B, we set A's error message
// and clear B's
isDueAtValid = (value, path, context) => {
let isValid = true
if (value && typeof value === 'object') {
this.messages[path] = this.getInvalidDateTimeMessage(value)
isValid = false
} else {
if (value && context.unlockAt) {
if (value < context.unlockAt) {
this.messages[path] = I18n.t('Due date must be after the Available date')
isValid = false
}
}
if (value && context.lockAt) {
if (value > context.lockAt) {
this.messages[path] = I18n.t('Due date must be before the Until date')
isValid = false
}
}
}
if (isValid) {
delete this.messages[path]
} else {
delete this.messages[path.replace('dueAt', 'unlockAt')]
delete this.messages[path.replace('dueAt', 'lockAt')]
}
return isValid
}
isUnlockAtValid = (value, path, context) => {
let isValid = true
if (value && typeof value === 'object') {
this.messages[path] = this.getInvalidDateTimeMessage(value)
isValid = false
} else {
if (value && context.dueAt) {
if (value > context.dueAt) {
this.messages[path] = I18n.t('Available date must be before the Due date')
isValid = false
}
}
if (value && context.lockAt) {
if (value > context.lockAt) {
this.messages[path] = I18n.t('Available date must be before the Until date')
isValid = false
}
}
}
if (isValid) {
delete this.messages[path]
} else {
delete this.messages[path.replace('unlockAt', 'dueAt')]
delete this.messages[path.replace('unlockAt', 'lockAt')]
}
return isValid
}
isLockAtValid = (value, path, context) => {
let isValid = true
if (value && typeof value === 'object') {
this.messages[path] = this.getInvalidDateTimeMessage(value)
isValid = false
} else {
if (value && context.dueAt) {
if (value < context.dueAt) {
this.messages[path] = I18n.t('Until date must be after the Due date')
isValid = false
}
}
if (value && context.unlockAt) {
if (value < context.unlockAt) {
this.messages[path] = I18n.t('Until date must be after the Available date')
isValid = false
}
}
}
if (isValid) {
delete this.messages[path]
} else {
delete this.messages[path.replace('lockAt', 'dueAt')]
delete this.messages[path.replace('lockAt', 'unlockAt')]
}
return isValid
}
validators = {
pointsPossible: this.isPointsValid,
name: this.isNameValid,
dueAt: this.isDueAtValid,
unlockAt: this.isUnlockAtValid,
lockAt: this.isLockAtValid
}
invalidFields = () => this.messages
validate = (path, value, context) => {
const validationPath = path.replace(/.*\./, '')
return this.validators[validationPath]
? this.validators[validationPath](value, path, context)
: true
}
errorMessage = path => this.messages[path]
}

View File

@ -1,38 +0,0 @@
/*
* Copyright (C) 2019 - 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/>.
*/
// beause isNan is not the same as Number.isNaN
/* eslint-disable no-restricted-globals */
export const isPointsValid = value => {
const strValue = `${value}`
if (!strValue) return false // it's required
if (isNaN(strValue)) return false // must be a number
return parseFloat(strValue) >= 0 // must be non-negative
}
/* eslint-enable no-restricted-globals */
export const isNameValid = value => !!value && value.trim().length > 0
const validators = {
pointsPossible: isPointsValid,
name: isNameValid
}
export const validate = (path, value) => (validators[path] ? validators[path](value) : true)
export default validators

View File

@ -0,0 +1,160 @@
/*
* Copyright (C) 2019 - 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 AssignmentFieldValidator from '../AssignentFieldValidator'
describe('Assignment field validators', () => {
const afv = new AssignmentFieldValidator()
it('validates points', () => {
expect(afv.isPointsValid('')).toBeFalsy()
expect(afv.errorMessage('pointsPossible')).toBeDefined()
expect(afv.isPointsValid(-1)).toBeFalsy()
expect(afv.errorMessage('pointsPossible')).toBeDefined()
expect(afv.isPointsValid('jibberish')).toBeFalsy()
expect(afv.errorMessage('pointsPossible')).toBeDefined()
expect(afv.isPointsValid(17)).toBeTruthy()
expect(afv.errorMessage('pointsPossible')).not.toBeDefined()
expect(afv.isPointsValid('17')).toBeTruthy()
expect(afv.errorMessage('pointsPossible')).not.toBeDefined()
})
it('validates the name', () => {
expect(afv.isNameValid('')).toBeFalsy()
expect(afv.errorMessage('name')).toBeDefined()
expect(afv.isNameValid()).toBeFalsy()
expect(afv.errorMessage('name')).toBeDefined()
expect(afv.isNameValid(' ')).toBeFalsy()
expect(afv.errorMessage('name')).toBeDefined()
expect(afv.isNameValid('hello')).toBeTruthy()
expect(afv.errorMessage('name')).not.toBeDefined()
})
it('gets the proper invalid date-time message', () => {
expect(afv.getInvalidDateTimeMessage({rawDateValue: 'jibberish', rawTimeValue: ''})).toBe(
'The date is not valid.'
)
expect(afv.getInvalidDateTimeMessage({rawDateValue: '', rawTimeValue: 'jibberish'})).toBe(
'You must provide a date with a time.'
)
expect(afv.getInvalidDateTimeMessage({rawDateValue: '', rawTimeValue: ''})).toBe(
'Invalid date or time'
)
})
const A = '2018-01-02T00:00Z'
const B = '2018-01-04T00:00Z'
const C = '2018-01-06T00:00Z'
it('validates dueAt', () => {
// the invalid date-time callback from DateTimeInputis routed correctly
expect(afv.isDueAtValid({rawDateValue: '', rawTimeValue: ''}, 'dueAt')).toBeFalsy()
expect(afv.errorMessage('dueAt')).toBeDefined()
expect(afv.errorMessage('unlockAt')).not.toBeDefined()
expect(afv.errorMessage('lockAt')).not.toBeDefined()
// due before unlock
expect(afv.isDueAtValid(A, 'dueAt', {unlockAt: B, lockAt: C})).toBeFalsy()
expect(afv.errorMessage('dueAt')).toBeDefined()
expect(afv.errorMessage('unlockAt')).not.toBeDefined()
expect(afv.errorMessage('lockAt')).not.toBeDefined()
// due after lock
expect(afv.isDueAtValid(C, 'dueAt', {unlockAt: A, lockAt: B})).toBeFalsy()
expect(afv.errorMessage('dueAt')).toBeDefined()
expect(afv.errorMessage('unlockAt')).not.toBeDefined()
expect(afv.errorMessage('lockAt')).not.toBeDefined()
expect(afv.isDueAtValid(B, 'dueAt', {unlockAt: A, lockAt: C})).toBeTruthy()
expect(afv.errorMessage('dueAt')).not.toBeDefined()
expect(afv.errorMessage('unlockAt')).not.toBeDefined()
expect(afv.errorMessage('lockAt')).not.toBeDefined()
})
it('validates unlockAt', () => {
// the invalid date-time callback from DateTimeInputis routed correctly
expect(afv.isUnlockAtValid({rawDateValue: '', rawTimeValue: ''}, 'unlockAt')).toBeFalsy()
expect(afv.errorMessage('dueAt')).not.toBeDefined()
expect(afv.errorMessage('unlockAt')).toBeDefined()
expect(afv.errorMessage('lockAt')).not.toBeDefined()
// unlock before due
expect(afv.isUnlockAtValid(B, 'unlockAt', {dueAt: A, lockAt: C})).toBeFalsy()
expect(afv.errorMessage('dueAt')).not.toBeDefined()
expect(afv.errorMessage('unlockAt')).toBeDefined()
expect(afv.errorMessage('lockAt')).not.toBeDefined()
// unlock after lock
expect(afv.isUnlockAtValid(B, 'unlockAt', {dueAt: C, lockAt: A})).toBeFalsy()
expect(afv.errorMessage('dueAt')).not.toBeDefined()
expect(afv.errorMessage('unlockAt')).toBeDefined()
expect(afv.errorMessage('lockAt')).not.toBeDefined()
expect(afv.isUnlockAtValid(A, 'unlockAt', {dueAt: B, lockAt: C})).toBeTruthy()
expect(afv.errorMessage('dueAt')).not.toBeDefined()
expect(afv.errorMessage('unlockAt')).not.toBeDefined()
expect(afv.errorMessage('lockAt')).not.toBeDefined()
})
it('validates lockAt', () => {
// the invalid date-time callback from DateTimeInputis routed correctly
expect(afv.isLockAtValid({rawDateValue: '', rawTimeValue: ''}, 'lockAt')).toBeFalsy()
expect(afv.errorMessage('dueAt')).not.toBeDefined()
expect(afv.errorMessage('unlockAt')).not.toBeDefined()
expect(afv.errorMessage('lockAt')).toBeDefined()
// lock before due
expect(afv.isLockAtValid(B, 'lockAt', {dueAt: C, unlockAt: A})).toBeFalsy()
expect(afv.errorMessage('dueAt')).not.toBeDefined()
expect(afv.errorMessage('unlockAt')).not.toBeDefined()
expect(afv.errorMessage('lockAt')).toBeDefined()
// lock before unlock
expect(afv.isLockAtValid(B, 'lockAt', {dueAt: A, unlockAt: C})).toBeFalsy()
expect(afv.errorMessage('dueAt')).not.toBeDefined()
expect(afv.errorMessage('unlockAt')).not.toBeDefined()
expect(afv.errorMessage('lockAt')).toBeDefined()
expect(afv.isLockAtValid(C, 'lockAt', {dueAt: B, unlockAt: A})).toBeTruthy()
expect(afv.errorMessage('dueAt')).not.toBeDefined()
expect(afv.errorMessage('unlockAt')).not.toBeDefined()
expect(afv.errorMessage('lockAt')).not.toBeDefined()
})
it('returns the invalid messages collection', () => {
expect(afv.isNameValid('')).toBeFalsy()
expect(Object.keys(afv.invalidFields())).toContain('name')
})
it('routes to the correct validator', () => {
expect(afv.validate('foo.bar.name', '', {})).toBeFalsy()
expect(afv.errorMessage('name')).toBeDefined()
})
it('returns true for no validator', () => {
expect(afv.validate('foo')).toBeTruthy()
})
})

View File

@ -65,6 +65,7 @@ export const TEACHER_QUERY = gql`
pointsPossible
state
needsGradingCount
onlyVisibleToOverrides
lockInfo {
isLocked
}
@ -288,6 +289,8 @@ export const SAVE_ASSIGNMENT = gql`
$name: String
$description: String
$dueAt: DateTime
$unlockAt: DateTime
$lockAt: DateTime
$pointsPossible: Float
$state: AssignmentState
) {
@ -297,6 +300,8 @@ export const SAVE_ASSIGNMENT = gql`
name: $name
description: $description
dueAt: $dueAt
unlockAt: $unlockAt
lockAt: $lockAt
pointsPossible: $pointsPossible
state: $state
}
@ -307,6 +312,8 @@ export const SAVE_ASSIGNMENT = gql`
lid: _id
gid: id
dueAt
unlockAt
lockAt
name
description
pointsPossible

View File

@ -28,6 +28,7 @@ ContentTabs.propTypes = {
assignment: TeacherAssignmentShape.isRequired,
onChangeAssignment: func.isRequired,
onValidate: func.isRequired,
invalidMessage: func.isRequired,
readOnly: bool
}
@ -44,6 +45,7 @@ export default function ContentTabs(props) {
assignment={assignment}
onChangeAssignment={props.onChangeAssignment}
onValidate={props.onValidate}
invalidMessage={props.invalidMessage}
readOnly={props.readOnly}
/>
</TabPanel>

View File

@ -31,6 +31,7 @@ Details.propTypes = {
assignment: TeacherAssignmentShape.isRequired,
onChangeAssignment: func.isRequired,
onValidate: func.isRequired,
invalidMessage: func.isRequired,
readOnly: bool
}
Details.defaultProps = {
@ -44,13 +45,13 @@ export default function Details(props) {
<AssignmentDescription
text={props.assignment.description}
onChange={handleDescriptionChange}
onValidate={props.onValidate}
readOnly={props.readOnly}
/>
<Overrides
assignment={props.assignment}
onChangeAssignment={props.onChangeAssignment}
onValidate={props.onValidate}
invalidMessage={props.invalidMessage}
readOnly={props.readOnly}
/>
{props.readOnly ? null : (

View File

@ -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 from 'react'
import {bool, func, oneOf, string} from 'prop-types'
import I18n from 'i18n!assignments_2'
import {showFlashAlert} from 'jsx/shared/FlashAlert'
import FormField from '@instructure/ui-form-field/lib/components/FormField'
import uid from '@instructure/uid'
import TeacherViewContext from '../TeacherViewContext'
import EditableDateTime from './EditableDateTime'
const fallbackErrorMessage = I18n.t('Invalid date-time')
export default class AssignmentDate extends React.Component {
static contextType = TeacherViewContext
static propTypes = {
mode: oneOf(['view', 'edit']).isRequired,
label: string.isRequired,
onChange: func.isRequired,
onChangeMode: func.isRequired,
onValidate: func.isRequired,
invalidMessage: func.isRequired,
value: string,
readOnly: bool
}
static defaultProps = {
readOnly: false
}
constructor(props) {
super(props)
this.state = {
isValid: props.onValidate(props.value)
}
this.id = uid() // FormField reqires an id
}
static getDerivedStateFromProps(props, state) {
const isValid = props.onValidate(props.value)
return isValid !== state.isValid ? {isValid} : null
}
handleDateChange = value => {
const isValid = this.props.onValidate(value)
this.setState({isValid}, () => {
if (!isValid) {
showFlashAlert({
message: this.props.invalidMessage() || fallbackErrorMessage,
type: 'error',
srOnly: true
})
}
this.props.onChange(value)
})
}
invalidDateTimeMessage = (rawDateValue, rawTimeValue) => {
this.props.onValidate({rawDateValue, rawTimeValue})
const message = this.props.invalidMessage() || fallbackErrorMessage
this.setState({isValid: false})
showFlashAlert({
message,
type: 'error',
srOnly: true
})
return message
}
getMessages = () =>
this.state.isValid
? null
: [{type: 'error', text: this.props.invalidMessage() || fallbackErrorMessage}]
render() {
const lbl = I18n.t('%{label}:', {label: this.props.label})
const placeholder = I18n.t('No %{label} Date', {label: this.props.label})
const messages = this.getMessages()
// can remove the outer DIV once instui is updated
// to forward data-* attrs from FormField into the dom
return (
<div data-testid="AssignmentDate">
<FormField id={this.id} label={lbl} layout="stacked">
<EditableDateTime
mode={this.props.mode}
onChange={this.handleDateChange}
onChangeMode={this.props.onChangeMode}
invalidMessage={this.invalidDateTimeMessage}
messages={messages}
value={this.props.value || undefined}
label={this.props.label}
locale={this.context.locale}
timeZone={this.context.timeZone}
readOnly={this.props.readOnly}
placeholder={placeholder}
/>
</FormField>
</div>
)
}
}

View File

@ -27,7 +27,6 @@ import {Text} from '@instructure/ui-elements'
import EditableHeading from './EditableHeading'
const nameLabel = I18n.t('Edit assignment name')
const invalidMessage = I18n.t('Assignment name is required')
const namePlaceholder = I18n.t('Assignment name')
export default class AssignmentName extends React.Component {
@ -37,6 +36,7 @@ export default class AssignmentName extends React.Component {
onChange: func.isRequired,
onChangeMode: func.isRequired,
onValidate: func.isRequired,
invalidMessage: func.isRequired,
readOnly: bool
}
@ -62,7 +62,7 @@ export default class AssignmentName extends React.Component {
this.setState({isValid}, () => {
if (!isValid) {
showFlashAlert({
message: invalidMessage,
message: this.props.invalidMessage('name') || I18n.t('Error'),
type: 'error',
srOnly: true
})
@ -74,7 +74,7 @@ export default class AssignmentName extends React.Component {
render() {
const msg = this.state.isValid ? null : (
<div>
<Text color="error">{invalidMessage}</Text>
<Text color="error">{this.props.invalidMessage('name')}</Text>
</div>
)
return (

View File

@ -27,7 +27,6 @@ import {Text} from '@instructure/ui-elements'
import EditableNumber from './EditableNumber'
const invalidMessage = I18n.t('Points must be a number >= 0')
const editLabel = I18n.t('Edit Points')
const label = I18n.t('Points')
@ -38,6 +37,7 @@ export default class AssignmentPoints extends React.Component {
onChange: func.isRequired,
onChangeMode: func.isRequired,
onValidate: func.isRequired,
invalidMessage: func.isRequired,
readOnly: bool
}
@ -79,7 +79,7 @@ export default class AssignmentPoints extends React.Component {
this.setState({isValid}, () => {
if (!isValid) {
showFlashAlert({
message: invalidMessage,
message: this.props.invalidMessage('pointsPossible') || I18n.t('Error'),
type: 'error',
srOnly: true
})
@ -93,7 +93,7 @@ export default class AssignmentPoints extends React.Component {
const msg = this.state.isValid ? null : (
<View as="div" textAlign="end" margin="xx-small 0 0 0">
<span style={{whiteSpace: 'nowrap'}}>
<Text color="error">{invalidMessage}</Text>
<Text color="error">{this.props.invalidMessage('pointsPossible')}</Text>
</span>
</View>
)

View File

@ -0,0 +1,272 @@
/*
* Copyright (C) 2019 - 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 {arrayOf, bool, func, shape, string} from 'prop-types'
import I18n from 'i18n!assignments_2'
import {toLocaleString} from '@instructure/ui-i18n/lib/DateTime'
import Button from '@instructure/ui-buttons/lib/components/Button'
import DateTimeInput from '@instructure/ui-forms/lib/components/DateTimeInput'
import IconCalendarMonth from '@instructure/ui-icons/lib/Line/IconCalendarMonth'
import {Editable} from '@instructure/ui-editable'
import {Flex, FlexItem, View} from '@instructure/ui-layout'
import {FocusableView} from '@instructure/ui-focusable'
import ScreenReaderContent from '@instructure/ui-a11y/lib/components/ScreenReaderContent'
import Text from '@instructure/ui-elements/lib/components/Text'
export default class EditableDateTime extends React.Component {
static propTypes = {
label: string.isRequired,
locale: string.isRequired,
timeZone: string.isRequired,
displayFormat: string,
value: string, // iso8601 datetime
mode: string.isRequired,
onChange: func.isRequired,
onChangeMode: func.isRequired,
invalidMessage: func.isRequired,
messages: arrayOf(shape({type: string.isRequired, text: string.isRequired})),
readOnly: bool,
required: bool,
placeholder: string
}
static defaultProps = {
displayFormat: 'lll',
readOnly: false,
required: false
}
constructor(props) {
super(props)
this.state = {
initialValue: props.value,
isValid: true
}
this._timers = [] // track the in-flight setTimeout timers
this._elementRef = null
}
componentWillUnmount() {
this._timers.forEach(t => window.clearTimeout(t))
}
// if a new value comes in while we're in view mode,
// reset our initial value
static getDerivedStateFromProps(props, _state) {
if (props.mode === 'view') {
return {
initialValue: props.value
}
}
return null
}
elementRef = el => {
this._elementRef = el
}
isValid() {
return this.state.isValid
}
// onChange handler from DateTimeInput
handleDateTimeChange = (_event, newValue) => {
this.setState({isValid: true}, () => {
this.props.onChange(newValue)
})
}
// onChange handler from Editable
handleChange = newValue => {
this.setState({isValid: true}, () => {
this.props.onChange(newValue)
})
}
handleChangeMode = mode => {
if (!this.props.readOnly) {
if (mode === 'view') {
if (!this.isValid()) {
// can't leave edit mode with a bad value
return
}
}
this.props.onChangeMode(mode)
}
}
// Because DateTimeInput has an asynchronous onBlur handler, we need to delay our call to
// this EditableDateTime's onFocus handler or focus gets yanked from this to the edit button of
// the one we just left if the user clicks from one to the next
delayedHandler = handler => {
return event => {
event.persist()
const t = window.setTimeout(() => {
this._timers.splice(this._timers.findIndex(tid => tid === t), 1)
handler(event)
}, 100)
this._timers.push(t)
}
}
// similar issue when clicking on the view
viewClickHandler(editableClickHandler) {
if (editableClickHandler) {
return this.delayedHandler(editableClickHandler)
}
return null
}
handleKey = event => {
if (event.key === 'Enter' && event.type === 'keydown') {
if (!this.props.readOnly) {
// let EditableDateTime handle the value change,
// then flip me to view mode
const t = window.setTimeout(() => {
this._timers.splice(this._timers.findIndex(tid => tid === t), 1)
this.props.onChangeMode('view')
}, 100)
this._timers.push(t)
}
} else if (event.key === 'Escape' && event.type === 'keyup') {
// Editable's keypup handler is what flips us to view mode
// so we'll reset to initial value on that event, not keydown
this.props.onChange(this.state.initialValue)
}
}
renderViewer = ({readOnly, mode}) => {
if (readOnly || mode === 'view') {
if (this.props.value) {
const dt = this.props.value
? toLocaleString(
this.props.value,
this.props.locale,
this.props.timeZone,
this.props.displayFormat
)
: ''
return <Text>{dt}</Text>
}
return <Text color="secondary">{this.props.placeholder}</Text>
}
return null
}
/* eslint-disable jsx-a11y/no-static-element-interactions */
renderEditor = ({mode, readOnly, onBlur, editorRef}) => {
if (!readOnly && mode === 'edit') {
return (
<div
onKeyDown={this.handleKey}
onKeyUp={this.handleKey}
data-testid="EditableDateTime-editor"
>
<FocusableView display="block" width="100%" focused>
<View display="inline-block" padding="x-small">
<DateTimeInput
layout="stacked"
description={<ScreenReaderContent>{this.props.label}</ScreenReaderContent>}
dateLabel={I18n.t('Date')}
datePreviousLabel={I18n.t('previous')}
dateNextLabel={I18n.t('next')}
timeLabel={I18n.t('Time')}
invalidDateTimeMessage={this.props.invalidMessage}
messages={this.props.messages}
value={this.props.value}
onChange={this.handleDateTimeChange}
onBlur={onBlur}
dateInputRef={editorRef}
required={this.props.required}
/>
</View>
</FocusableView>
</div>
)
}
return null
}
/* eslint-enable jsx-a11y/no-static-element-interactions */
// Renders the edit button.
// Returns a custom edit button with the calendar icon and is always visible in view mode
renderEditButton = ({onClick, onFocus, onBlur, buttonRef}) => {
if (!this.props.readOnly && this.props.mode === 'view') {
return (
<Button
size="small"
variant="icon"
margin="0 0 0 x-small"
icon={IconCalendarMonth}
onClick={onClick}
onFocus={this.delayedHandler(onFocus)}
onBlur={onBlur}
buttonRef={buttonRef}
readOnly={this.props.readOnly}
>
<ScreenReaderContent>
{I18n.t('Edit %{when}', {when: this.props.label})}
</ScreenReaderContent>
</Button>
)
}
return null
}
renderAll = ({getContainerProps, getViewerProps, getEditorProps, getEditButtonProps}) => {
const borderWidth = this.props.mode === 'view' ? 'small' : 'none'
const padding = this.props.mode === 'view' ? 'x-small' : '0'
const containerProps = {...getContainerProps()}
containerProps.onMouseDown = this.viewClickHandler(containerProps.onMouseDown)
return (
<View
data-testid="EditableDateTime"
as="div"
padding={padding}
borderWidth={borderWidth}
borderRadius="medium"
elementRef={this.elementRef}
{...containerProps}
>
<Flex inline direction="row" justifyItems="space-between" width="100%">
<FlexItem grow shrink>
{this.renderEditor(getEditorProps())}
{this.renderViewer(getViewerProps())}
</FlexItem>
<FlexItem margin="0 0 0 xx-small">{this.renderEditButton(getEditButtonProps())}</FlexItem>
</Flex>
</View>
)
}
render() {
return (
<Editable
mode={this.props.mode}
onChangeMode={this.handleChangeMode}
render={this.renderAll}
value={this.props.value}
onChange={this.handleChange}
readOnly={this.props.readOnly}
/>
)
}
}

View File

@ -60,11 +60,11 @@ export default class EditableHeading extends React.Component {
this._hiddenTextRef = null
}
getSnapshotBeforeUpdate(prevProps, _prevState) {
if (prevProps.mode === 'view' && this._headingRef) {
const fontSize = this.getFontSize(this._headingRef)
// we'll set the width of the <input> to the width of the text + 1 char
return {width: this._headingRef.clientWidth + fontSize}
static getDerivedStateFromProps(props, _state) {
if (props.mode === 'view') {
return {
initialValue: props.value
}
}
return null
}
@ -175,6 +175,7 @@ export default class EditableHeading extends React.Component {
value={this.props.value}
onChange={this.handleInputChange}
onKeyDown={this.handleKey}
onKeyUp={this.handleKey}
aria-label={this.props.label}
onBlur={onBlur}
elementRef={createChainedFunction(this.getInputRef, editorRef)}
@ -194,13 +195,13 @@ export default class EditableHeading extends React.Component {
// don't have to check what mode is, because
// this is the editor's key handler
handleKey = event => {
if (event.key === 'Enter') {
if (event.key === 'Enter' && event.type === 'keydown') {
event.preventDefault()
event.stopPropagation()
if (!this.props.readOnly) {
this.props.onChangeMode('view')
}
} else if (event.key === 'Escape') {
} else if (event.key === 'Escape' && event.type === 'keyup') {
// reset to initial value
this.props.onChange(this.state.initialValue)
}
@ -210,17 +211,8 @@ export default class EditableHeading extends React.Component {
this.props.onChange(event.target.value)
}
// InPlaceEdit.onChange is fired when changing from edit to view
// mode. Reset the initialValue now.
handleChange = newValue => {
this.setState(
{
initialValue: newValue
},
() => {
this.props.onChange(newValue)
}
)
this.props.onChange(newValue)
}
handleModeChange = mode => {

View File

@ -66,6 +66,17 @@ export default class EditableNumber extends React.Component {
this._hiddenTextRef = null
}
// if a new value comes in while we're in view mode,
// reset our initial value
static getDerivedStateFromProps(props, _state) {
if (props.mode === 'view') {
return {
initialValue: props.value
}
}
return null
}
componentDidUpdate(prevProps, _prevState, _snapshot) {
if (this._inputRef) {
this._inputRef.style.width = this.getWidth()
@ -212,14 +223,7 @@ export default class EditableNumber extends React.Component {
// InPlaceEdit.onChange is fired when changing from edit to view
// mode. Reset the initialValue now.
handleChange = newValue => {
this.setState(
{
initialValue: newValue
},
() => {
this.props.onChange(newValue)
}
)
this.props.onChange(newValue)
}
handleModeChange = mode => {

View File

@ -0,0 +1,124 @@
/*
* Copyright (C) 2019 - 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 {render, fireEvent} from 'react-testing-library'
import {toLocaleString, browserTimeZone} from '@instructure/ui-i18n/lib/DateTime'
import AssignmentDate from '../AssignmentDate'
const locale = 'en'
const timeZone = browserTimeZone()
function renderAssignmentDate(props) {
return render(
<AssignmentDate
mode="view"
label="Due"
onChange={() => {}}
onChangeMode={() => {}}
onValidate={() => true}
invalidMessage={() => 'oh no!'}
value="2108-03-13T15:15:00-07:00"
{...props}
/>
)
}
describe('AssignmentDate', () => {
it('renders in view mode', () => {
const {getByTestId} = renderAssignmentDate()
expect(getByTestId('AssignmentDate')).toBeInTheDocument()
expect(getByTestId('EditableDateTime')).toBeInTheDocument()
})
it('renders in edit mode', () => {
const {getByTestId} = renderAssignmentDate({mode: 'edit'})
expect(getByTestId('AssignmentDate')).toBeInTheDocument()
expect(getByTestId('EditableDateTime')).toBeInTheDocument()
expect(getByTestId('EditableDateTime-editor')).toBeInTheDocument()
})
it('shows error message with invalid value when in edit mode', () => {
// because the error message is rendered by the instui DateTimeInput
const {getByText} = renderAssignmentDate({mode: 'edit', onValidate: () => false})
expect(getByText('oh no!')).toBeInTheDocument()
})
it('does not show error message in view mode', () => {
// because the error message is hoisted to the parent OverrideDates
const {queryByText} = renderAssignmentDate({mode: 'view', onValidate: () => false})
expect(queryByText('oh no!')).toBeNull()
})
it('shows the placeholder when the value is empty', () => {
const {getByText} = renderAssignmentDate({value: null})
expect(getByText('No Due Date')).toBeInTheDocument()
})
it('handles jibberish date input', () => {
const value = '2108-03-13T15:15:00-07:00'
const invalidMessage = jest.fn()
const {getByDisplayValue} = renderAssignmentDate({
mode: 'edit',
onValidate: () => true,
invalidMessage,
value
})
const dateDisplay = toLocaleString(value, locale, timeZone, 'LL')
const dinput = getByDisplayValue(dateDisplay)
dinput.focus()
fireEvent.change(dinput, {target: {value: 'x'}})
const timeDisplay = toLocaleString(value, locale, timeZone, 'LT')
const tinput = getByDisplayValue(timeDisplay)
tinput.focus()
expect(invalidMessage).toHaveBeenCalled()
})
it('handles input', () => {
function validator(value) {
const d = new Date(value)
const reference = new Date('2108-04-13T15:15:00-07:00')
return d.valueOf() < reference.valueOf()
}
const value = '2108-03-13T15:15:00-07:00'
const invalidMessage = jest.fn()
const {getByDisplayValue} = renderAssignmentDate({
mode: 'edit',
onValidate: validator,
invalidMessage,
value
})
const dateDisplay = toLocaleString(value, locale, timeZone, 'LL')
const dinput = getByDisplayValue(dateDisplay)
dinput.focus()
fireEvent.change(dinput, {target: {value: '2108-05-13T15:15:00-07:00'}})
const timeDisplay = toLocaleString(value, locale, timeZone, 'LT')
const tinput = getByDisplayValue(timeDisplay)
tinput.focus()
expect(invalidMessage).toHaveBeenCalled()
})
})

View File

@ -19,7 +19,15 @@
import React from 'react'
import {render, fireEvent} from 'react-testing-library'
import AssignmentName from '../AssignmentName'
import {validate} from '../../../Validators'
import AssignmentFieldValidator from '../../../AssignentFieldValidator'
const afv = new AssignmentFieldValidator()
function validate() {
return afv.validate(...arguments)
}
function errorMessage() {
return afv.errorMessage(...arguments)
}
function renderAssignmentName(props) {
return render(
@ -28,6 +36,7 @@ function renderAssignmentName(props) {
onChange={() => {}}
onChangeMode={() => {}}
onValidate={validate}
invalidMessage={errorMessage}
name="the name"
{...props}
/>
@ -86,6 +95,7 @@ describe('AssignmentName', () => {
onChange={onChange}
onChangeMode={onChangeMode}
onValidate={() => true}
invalidMessage={() => undefined}
name="the name"
/>
<span id="focus-me" tabIndex="-1">
@ -101,15 +111,4 @@ describe('AssignmentName', () => {
expect(onChangeMode).toHaveBeenCalledWith('view')
expect(onChange).toHaveBeenCalledWith('new name')
})
it('reverts to the old value on Escape', () => {
const onChange = jest.fn()
const {getByDisplayValue} = renderAssignmentName({mode: 'edit', onChange})
const input = getByDisplayValue('the name')
fireEvent.input(input, {target: {value: 'x'}})
fireEvent.keyDown(input, {key: 'Escape', code: 27})
expect(onChange).toHaveBeenCalledWith('x')
expect(onChange).toHaveBeenCalledWith('the name')
})
})

View File

@ -19,7 +19,15 @@
import React from 'react'
import {render, fireEvent} from 'react-testing-library'
import AssignmentPoints from '../AssignmentPoints'
import {validate} from '../../../Validators'
import AssignmentFieldValidator from '../../../AssignentFieldValidator'
const afv = new AssignmentFieldValidator()
function validate() {
return afv.validate(...arguments)
}
function errorMessage() {
return afv.errorMessage(...arguments)
}
function renderAssignmentPoints(props) {
return render(
@ -28,6 +36,7 @@ function renderAssignmentPoints(props) {
onChange={() => {}}
onChangeMode={() => {}}
onValidate={validate}
invalidMessage={errorMessage}
pointsPossible={1432}
{...props}
/>
@ -73,6 +82,7 @@ describe('AssignmentPoints', () => {
onChange={onChange}
onChangeMode={onChangeMode}
onValidate={() => true}
invalidMessage={() => undefined}
pointsPossible={12}
/>
<span id="focus-me" tabIndex="-1">
@ -108,6 +118,7 @@ describe('AssignmentPoints', () => {
onChange={onChange}
onChangeMode={() => {}}
onValidate={validate}
invalidMessage={errorMessage}
pointsPossible="1.247"
/>
<input data-testid="focusme" />

View File

@ -0,0 +1,169 @@
/*
* Copyright (C) 2019 - 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 {render, fireEvent} from 'react-testing-library'
import {toLocaleString, browserTimeZone} from '@instructure/ui-i18n/lib/DateTime'
import moment from 'moment'
import EditableDateTime from '../EditableDateTime'
const locale = 'en'
const timeZone = browserTimeZone()
function renderEditableDateTime(props = {}) {
return render(
<EditableDateTime
mode="view"
onChange={() => {}}
onChangeMode={() => {}}
value="2019-04-11T13:00:00-05:00"
label="Due"
locale={locale}
timeZone={timeZone}
readOnly={false}
placeholder="No due date"
displayFormat="lll"
invalidMessage={() => undefined}
{...props}
/>
)
}
describe('EditableDateTime', () => {
it('renders in view mode', () => {
const value = '2019-04-11T13:00:00-05:00'
const {getByText} = renderEditableDateTime({value})
const dtstring = toLocaleString(value, locale, timeZone, 'lll')
expect(getByText('Edit Due')).toBeInTheDocument()
expect(getByText(dtstring)).toBeInTheDocument()
})
it('renders in edit mode', () => {
const value = '2019-04-11T13:00:00-05:00'
const {getByText, getByLabelText} = renderEditableDateTime({mode: 'edit', value})
const dtstring = toLocaleString(value, locale, timeZone, 'LLL')
const datestr = toLocaleString(value, locale, timeZone, 'LL')
const timestr = toLocaleString(value, locale, timeZone, 'LT')
expect(getByLabelText('Date').value).toBe(datestr)
expect(getByLabelText('Time').value).toBe(timestr)
expect(getByText(dtstring)).toBeInTheDocument()
})
it('exits edit mode and reverts to previous value on Escape', () => {
const value = '2019-04-11T13:00:00-05:00'
const onChangeMode = jest.fn()
const onChange = jest.fn()
const {getByDisplayValue} = renderEditableDateTime({
mode: 'edit',
onChangeMode,
onChange,
value
})
const datestr = toLocaleString(value, locale, timeZone, 'LL')
const timestr = toLocaleString(value, locale, timeZone, 'LT')
const dinput = getByDisplayValue(datestr)
const tinput = getByDisplayValue(timestr)
// enter a new date
fireEvent.change(dinput, {target: {value: 'April 4, 2018'}})
tinput.focus()
const newDateIsoStr = moment('2018-04-04T13:00:00-05:00').toISOString(true)
expect(onChange).toHaveBeenLastCalledWith(newDateIsoStr)
dinput.focus()
fireEvent.keyUp(dinput, {key: 'Escape', code: 27})
expect(onChange).toHaveBeenLastCalledWith(value)
expect(onChangeMode).toHaveBeenLastCalledWith('view')
})
// I've spent a day trying to get the folloning specs to work, but I have failed
// to find the combination of simulated events that get the right event
// handlers called. Events are getting lost among
// input -> TextInput -> DateInput -> DateTimeInput -> Editable -> EditableDateTime
// It works in the UI, but I've failed to simulate the user's input
// it('saves new value on Enter', async () => {
// const value = '2018-04-11T13:00:00-05:00'
// const onChange = jest.fn()
// const onChangeMode = jest.fn()
// const {container} = renderEditableDateTime({
// mode: 'edit',
// onChange,
// onChangeMode,
// value
// })
// const input = container.querySelector('[data-testid="EditableDateTime-editor"] input')
// const newdt = toLocaleString('2019-04-11T13:00:00-05:00', locale, timeZone, 'LL')
// fireEvent.change(input, {target: {value: newdt}})
// fireEvent.keyDown(input, {key: 'Enter', code: 13, target: {value: input.value}})
// await wait(() => {
// expect(onChangeMode).toHaveBeenCalledWith('view')
// })
// await wait(() => {
// expect(onChange).toHaveBeenCalled()
// expect(onChange).toHaveBeenCalledWith('2019-04-11T13:00:00-05:00')
// })
// })
// it('saves the new value on blur', () => {
// const value = '2019-04-11T13:00:00-05:00'
// const onChange = jest.fn()
// const onChangeMode = jest.fn()
// const {container, getByDisplayValue} = render(
// <div>
// <EditableDateTime
// mode="edit"
// onChange={onChange}
// onChangeMode={onChangeMode}
// value={value}
// label="Due"
// locale={locale}
// timeZone={timeZone}
// readOnly={false}
// placeholder="No due date"
// displayFormat="lll"
// invalidMessage={() => undefined}
// />
// <span id="focus-me" tabIndex="-1">
// just here to get focus
// </span>
// </div>
// )
// let displayValue = toLocaleString(value, locale, timeZone, 'LL')
// const input = getByDisplayValue(displayValue)
// input.focus()
// const newValue = '2019-04-12T13:00:00-05:00'
// displayValue = toLocaleString(newValue, locale, timeZone, 'LL')
// fireEvent.change(input, {target: {value: displayValue}})
// container.querySelector('#focus-me').focus()
// expect(onChangeMode).toHaveBeenCalledWith('view')
// expect(onChange).toHaveBeenCalledWith(newValue)
// })
// it('reverts to the old value on Escape', () => {
// // if I can't get change to work, I can't revert...
// })
})

View File

@ -20,65 +20,90 @@ import React from 'react'
import {render, fireEvent} from 'react-testing-library'
import EditableHeading from '../EditableHeading'
it('renders the value in view mode', () => {
const {getByText} = render(
<EditableHeading
mode="view"
onChange={() => {}}
onChangeMode={() => {}}
label="Book title"
value="Another Roadside Attraction"
level="h3"
/>
)
describe('EditableHeading', () => {
it('renders the value in view mode', () => {
const {getByText} = render(
<EditableHeading
mode="view"
onChange={() => {}}
onChangeMode={() => {}}
label="Book title"
value="Another Roadside Attraction"
level="h3"
/>
)
expect(getByText('Another Roadside Attraction')).toBeInTheDocument()
expect(document.querySelector('h3')).toBeInTheDocument()
})
it('renders the value in edit mode', () => {
const {getByDisplayValue} = render(
<EditableHeading
mode="edit"
onChange={() => {}}
onChangeMode={() => {}}
label="Book title"
value="Still Life with Woodpecker"
level="h3"
/>
)
expect(getByDisplayValue('Still Life with Woodpecker')).toBeInTheDocument()
})
it('does not render edit button when readOnly', () => {
const {queryByText} = render(
<EditableHeading
mode="view"
onChange={() => {}}
onChangeMode={() => {}}
label="Edit title"
value="Still Life with Woodpecker"
level="h3"
readOnly
/>
)
expect(queryByText('Edit title')).toBeNull()
})
it('exits edit mode on <Enter>', () => {
const onChangeMode = jest.fn()
const {getByDisplayValue} = render(
<EditableHeading
mode="edit"
onChange={() => {}}
onChangeMode={onChangeMode}
label="Book title"
value="Jitterbug Perfume"
level="h3"
/>
)
const input = getByDisplayValue('Jitterbug Perfume')
fireEvent.keyDown(input, {key: 'Enter', code: 13})
expect(onChangeMode).toHaveBeenCalledWith('view')
expect(getByText('Another Roadside Attraction')).toBeInTheDocument()
expect(document.querySelector('h3')).toBeInTheDocument()
})
it('renders the value in edit mode', () => {
const {getByDisplayValue} = render(
<EditableHeading
mode="edit"
onChange={() => {}}
onChangeMode={() => {}}
label="Book title"
value="Still Life with Woodpecker"
level="h3"
/>
)
expect(getByDisplayValue('Still Life with Woodpecker')).toBeInTheDocument()
})
it('does not render edit button when readOnly', () => {
const {queryByText} = render(
<EditableHeading
mode="view"
onChange={() => {}}
onChangeMode={() => {}}
label="Edit title"
value="Even Cowgirls Get the Blues"
level="h3"
readOnly
/>
)
expect(queryByText('Edit title')).toBeNull()
})
it('exits edit mode on <Enter>', () => {
const onChangeMode = jest.fn()
const {getByDisplayValue} = render(
<EditableHeading
mode="edit"
onChange={() => {}}
onChangeMode={onChangeMode}
label="Book title"
value="Jitterbug Perfume"
level="h3"
/>
)
const input = getByDisplayValue('Jitterbug Perfume')
fireEvent.keyDown(input, {key: 'Enter', code: 13})
expect(onChangeMode).toHaveBeenCalledWith('view')
})
it('reverts to the old value and exits edit mode on Escape', () => {
const onChange = jest.fn()
const onChangeMode = jest.fn()
const {getByDisplayValue} = render(
<EditableHeading
mode="edit"
onChange={onChange}
onChangeMode={onChangeMode}
label="Book title"
value="Half Asleep in Frog Pajamas"
level="h3"
/>
)
const input = getByDisplayValue('Half Asleep in Frog Pajamas')
fireEvent.change(input, {target: {value: 'x'}})
expect(onChange).toHaveBeenLastCalledWith('x')
fireEvent.keyUp(input, {key: 'Escape', code: 27})
expect(onChange).toHaveBeenLastCalledWith('Half Asleep in Frog Pajamas')
expect(onChangeMode).toHaveBeenLastCalledWith('view')
})
})

View File

@ -78,14 +78,15 @@ describe('EditableNumber', () => {
expect(onChangeMode).toHaveBeenCalledWith('view')
})
it('reverts to the old value on Escape', () => {
it('reverts to the old value and exits edit mode on Escape', () => {
const onChange = jest.fn()
const onInputChange = jest.fn()
const onChangeMode = jest.fn()
const {getByDisplayValue} = render(
<EditableNumber
mode="edit"
onChange={onChange}
onChangeMode={() => {}}
onChangeMode={onChangeMode}
onInputChange={onInputChange}
label="Pick a number"
value="17"
@ -93,10 +94,12 @@ describe('EditableNumber', () => {
)
const input = getByDisplayValue('17')
fireEvent.input(input, {target: {value: '2'}})
fireEvent.keyDown(input, {key: 'Escape', code: 27})
expect(onInputChange).toHaveBeenCalledWith('2')
expect(onChange).toHaveBeenCalledWith('17')
fireEvent.change(input, {target: {value: '2'}})
expect(onInputChange).toHaveBeenLastCalledWith('2')
fireEvent.keyUp(input, {key: 'Escape', code: 27})
expect(onChange).toHaveBeenLastCalledWith('17')
expect(onChangeMode).toHaveBeenLastCalledWith('view')
})
// I want to test that the input grows in width as the user

View File

@ -60,6 +60,7 @@ export default class Header extends React.Component {
assignment: TeacherAssignmentShape.isRequired,
onChangeAssignment: func.isRequired,
onValidate: func.isRequired,
invalidMessage: func.isRequired,
onSetWorkstate: func.isRequired,
onUnsubmittedClick: func,
onPublishChange: func,
@ -215,6 +216,7 @@ export default class Header extends React.Component {
onChange={this.handleNameChange}
onChangeMode={this.handleNameChangeMode}
onValidate={this.props.onValidate}
invalidMessage={this.props.invalidMessage}
readOnly={this.props.readOnly}
/>
</View>

View File

@ -30,6 +30,8 @@ export default class EveryoneElse extends React.Component {
static propTypes = {
assignment: TeacherAssignmentShape.isRequired,
onChangeAssignment: func.isRequired,
onValidate: func.isRequired,
invalidMessage: func.isRequired,
readOnly: bool
}
@ -42,9 +44,19 @@ export default class EveryoneElse extends React.Component {
this.props.onChangeAssignment(path, value)
}
// see
// OverrideListPresenter.due_for calling
// OverrideListPresenter.multiple_due_dates calling
// assignment.has_active_overrides
hasActiveOverrides() {
return (
this.props.assignment.assignmentOverrides.nodes &&
this.props.assignment.assignmentOverrides.nodes.length
)
}
overrideFromAssignment(assignment) {
const title =
assignment.assignmentOverrides.nodes.length > 0 ? I18n.t('Everyone else') : I18n.t('Everyone')
const title = this.hasActiveOverrides() ? I18n.t('Everyone else') : I18n.t('Everyone')
const fauxOverride = {
gid: `assignment_${assignment.id}`,
@ -70,6 +82,8 @@ export default class EveryoneElse extends React.Component {
<Override
override={fauxOverride}
onChangeOverride={this.handleChangeOverride}
onValidate={this.props.onValidate}
invalidMessage={this.props.invalidMessage}
index={-1}
readOnly={this.props.readOnly}
/>

View File

@ -25,34 +25,65 @@ import View from '@instructure/ui-layout/lib/components/View'
import OverrideSummary from './OverrideSummary'
import OverrideDetail from './OverrideDetail'
Override.propTypes = {
override: OverrideShape.isRequired,
onChangeOverride: func.isRequired,
index: number.isRequired, // offset of this override in the assignment
readOnly: bool
}
Override.defaultProps = {
readOnly: false
}
export default class Override extends React.Component {
static propTypes = {
override: OverrideShape.isRequired,
onChangeOverride: func.isRequired,
onValidate: func.isRequired,
invalidMessage: func.isRequired,
index: number.isRequired, // offset of this override in the assignment
readOnly: bool
}
export default function Override(props) {
return (
<View as="div" margin="0 0 small 0" data-testid="Override">
<ToggleGroup
toggleLabel={I18n.t('Expand')}
summary={<OverrideSummary override={props.override} />}
background="default"
>
<OverrideDetail
override={props.override}
onChangeOverride={onChangeOverride}
readOnly={props.readOnly}
/>
</ToggleGroup>
</View>
)
static defaultProps = {
readOnly: false
}
function onChangeOverride(path, value) {
props.onChangeOverride(props.index, path, value)
constructor(props) {
super(props)
this.state = {
expanded: false
}
}
handleChangeOverride = (path, value) => {
return this.props.onChangeOverride(this.props.index, path, value)
}
handleValidate = (path, value) => {
return this.props.onValidate(this.props.index, path, value)
}
invalidMessage = path => {
return this.props.invalidMessage(this.props.index, path)
}
handleToggle = (_event, expanded) => {
this.setState({expanded})
}
render() {
return (
<View as="div" margin="0 0 small 0" data-testid="Override">
<ToggleGroup
expanded={this.state.expanded}
onToggle={this.handleToggle}
toggleLabel={
this.state.expanded ? I18n.t('Click to hide details') : I18n.t('Click to show details')
}
summary={<OverrideSummary override={this.props.override} />}
background="default"
>
<OverrideDetail
override={this.props.override}
onChangeOverride={this.handleChangeOverride}
onValidate={this.handleValidate}
invalidMessage={this.invalidMessage}
readOnly={this.props.readOnly}
/>
</ToggleGroup>
</View>
)
}
}

View File

@ -16,63 +16,145 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from 'react'
import {bool, string} from 'prop-types'
import {bool, func, oneOf, string} from 'prop-types'
import I18n from 'i18n!assignments_2'
import FriendlyDatetime from '../../../../shared/FriendlyDatetime'
import IconCalendarMonth from '@instructure/ui-icons/lib/Line/IconCalendarMonth'
import FormField from '@instructure/ui-form-field/lib/components/FormField'
import Flex, {FlexItem} from '@instructure/ui-layout/lib/components/Flex'
import View from '@instructure/ui-layout/lib/components/View'
import generateElementId from '@instructure/ui-utils/lib/dom/generateElementId'
import {Flex, FlexItem} from '@instructure/ui-layout'
import {FormFieldGroup} from '@instructure/ui-forms'
import {ScreenReaderContent} from '@instructure/ui-a11y'
OverrideDates.propTypes = {
dueAt: string,
unlockAt: string,
lockAt: string,
readOnly: bool
}
import AssignmentDate from '../Editables/AssignmentDate'
OverrideDates.defaultProps = {
readOnly: false
}
export default class OverrideDates extends React.Component {
static propTypes = {
mode: oneOf(['view', 'edit']), // TODO: needs to be isReqired from above
onChange: func.isRequired,
onValidate: func.isRequired,
invalidMessage: func.isRequired,
dueAt: string,
unlockAt: string,
lockAt: string,
readOnly: bool
}
export default function OverrideDates(props) {
return (
<Flex
as="div"
margin="small 0"
padding="0"
justifyItems="space-between"
wrapItems
data-testid="OverrideDates"
>
<FlexItem margin="0 x-small small 0" as="div" grow>
{renderDate(I18n.t('Due:'), props.dueAt)}
</FlexItem>
<FlexItem margin="0 x-small small 0" as="div" grow>
{renderDate(I18n.t('Available:'), props.unlockAt)}
</FlexItem>
<FlexItem margin="0 0 small 0" as="div" grow>
{renderDate(I18n.t('Until:'), props.lockAt)}
</FlexItem>
</Flex>
)
}
static defaultProps = {
readOnly: false
}
function renderDate(label, value) {
const id = generateElementId('overidedate')
return (
<FormField id={id} label={label} layout="stacked">
<View id={id} as="div" padding="x-small" borderWidth="small" borderRadius="medium">
<Flex justifyItems="space-between">
<FlexItem>
{value && <FriendlyDatetime dateTime={value} format={I18n.t('#date.formats.full')} />}
constructor(props) {
super(props)
const mode = props.mode || 'view'
this.state = {
dueMode: mode,
unlockMode: mode,
lockMode: mode
}
}
onChangeDue = newValue => this.props.onChange('dueAt', newValue)
onChangeUnlock = newValue => this.props.onChange('unlockAt', newValue)
onChangeLock = newValue => this.props.onChange('lockAt', newValue)
onChangeDueMode = dueMode => this.setState({dueMode})
onChangeUnlockMode = unlockMode => this.setState({unlockMode})
onChangeLockMode = lockMode => this.setState({lockMode})
onValidateDue = value => this.props.onValidate('dueAt', value)
onValidateUnlock = value => this.props.onValidate('unlockAt', value)
onValidateLock = value => this.props.onValidate('lockAt', value)
invalidMessageDue = () => this.props.invalidMessage('dueAt')
invalidMessageUnlock = () => this.props.invalidMessage('unlockAt')
invalidMessageLock = () => this.props.invalidMessage('lockAt')
allDatesAreBeingViewed = () =>
this.state.dueMode === 'view' &&
this.state.unlockMode === 'view' &&
this.state.lockMode === 'view'
renderDate(field, label, value, mode, onchange, onchangemode, onvalidate, invalidMessage) {
return (
<AssignmentDate
mode={mode}
onChange={onchange}
onChangeMode={onchangemode}
onValidate={onvalidate}
invalidMessage={invalidMessage}
field={field}
value={value}
label={label}
readOnly={this.props.readOnly}
/>
)
}
render() {
// show an error message only when all dates are in view
const message =
this.allDatesAreBeingViewed() &&
(this.invalidMessageDue() || this.invalidMessageUnlock() || this.invalidMessageLock())
return (
<FormFieldGroup
description={
<ScreenReaderContent>{I18n.t('Due, available, and until dates')}</ScreenReaderContent>
}
messages={message ? [{type: 'error', text: message}] : null}
>
<Flex
as="div"
margin="small 0"
padding="0"
justifyItems="space-between"
alignItems="start"
wrapItems
data-testid="OverrideDates"
>
<FlexItem margin="0 x-small 0 0" as="div" grow width="30%">
{this.renderDate(
'due_at',
I18n.t('Due'),
this.props.dueAt,
this.state.dueMode,
this.onChangeDue,
this.onChangeDueMode,
this.onValidateDue,
this.invalidMessageDue
)}
</FlexItem>
<FlexItem padding="0 0 xx-small x-small">
<IconCalendarMonth />
<FlexItem margin="0 x-small 0 0" as="div" grow width="30%">
{this.renderDate(
'unlock_at',
I18n.t('Available'),
this.props.unlockAt,
this.state.unlockMode,
this.onChangeUnlock,
this.onChangeUnlockMode,
this.onValidateUnlock,
this.invalidMessageUnlock
)}
</FlexItem>
<FlexItem margin="0 0 0 0" as="div" grow width="30%">
{this.renderDate(
'lock_at',
I18n.t('Until'),
this.props.lockAt,
this.state.lockMode,
this.onChangeLock,
this.onChangeLockMode,
this.onValidateLock,
this.invalidMessageLock
)}
</FlexItem>
</Flex>
</View>
</FormField>
)
</FormFieldGroup>
)
}
}

View File

@ -28,6 +28,8 @@ export default class OverrideDetail extends React.Component {
static propTypes = {
override: OverrideShape.isRequired,
onChangeOverride: func.isRequired,
onValidate: func.isRequired,
invalidMessage: func.isRequired,
readOnly: bool
}
@ -35,6 +37,10 @@ export default class OverrideDetail extends React.Component {
readOnly: false
}
handleChangeDate = (which, value) => {
this.props.onChangeOverride(which, value)
}
renderAssignedTo() {
return <OverrideAssignTo override={this.props.override} variant="detail" />
}
@ -45,6 +51,10 @@ export default class OverrideDetail extends React.Component {
dueAt={this.props.override.dueAt}
unlockAt={this.props.override.unlockAt}
lockAt={this.props.override.lockAt}
onChange={this.handleChangeDate}
onValidate={this.props.onValidate}
invalidMessage={this.props.invalidMessage}
readOnly={this.props.readOnly}
/>
)
}

View File

@ -52,23 +52,45 @@ export default class OverrideSummary extends React.Component {
return (
<Text>
<OverrideSubmissionTypes variant="summary" override={override} />
<Text> | </Text>
<FriendlyDatetime
prefix={I18n.t('Due: ')}
dateTime={override.dueAt}
format={I18n.t('#date.formats.full')}
/>
<Text>
<div style={{display: 'inline-block', padding: '0 .5em'}}>|</div>
{override.dueAt ? (
<FriendlyDatetime
prefix={I18n.t('Due: ')}
dateTime={override.dueAt}
format={I18n.t('#date.formats.full')}
/>
) : (
I18n.t('No Due Date')
)}
</Text>
</Text>
)
}
// it's unfortunate but when both unlock and lock dates exist
// AvailabilityDates only prefixes with "Available" if the formatStyle="long"
// If I chnage it there, it will alter the Student view
renderAvailability(override) {
return (
<Text>
{I18n.t('Available ')}
<AvailabilityDates assignment={override} formatStyle="short" />
</Text>
)
// both dates exist, manually add Available prefix
if (override.unlockAt && override.lockAt) {
return (
<Text>
{I18n.t('Available ')}
<AvailabilityDates assignment={override} formatStyle="short" />
</Text>
)
}
// only one date exists, AvailabilityDates will include the Available prefix
if (override.unlockAt || override.lockAt) {
return (
<Text>
<AvailabilityDates assignment={override} formatStyle="short" />
</Text>
)
}
// no dates exist, so the assignment is simply Available
return <Text>{I18n.t('Available')}</Text>
}
render() {

View File

@ -29,6 +29,8 @@ export default class Overrides extends React.Component {
static propTypes = {
assignment: TeacherAssignmentShape.isRequired,
onChangeAssignment: func.isRequired,
onValidate: func.isRequired,
invalidMessage: func.isRequired,
readOnly: bool
}
@ -37,24 +39,34 @@ export default class Overrides extends React.Component {
}
handleChangeOverride = (overrideIndex, path, value) => {
if (path === 'allowedAttempts' || path === 'submissionTypes') {
const hoistToAssignment = ['allowedAttempts', 'submissionTypes']
if (hoistToAssignment.includes(path)) {
this.props.onChangeAssignment(path, value)
} else {
this.props.onChangeAssignment(`assignmentOverrides.nodes.${overrideIndex}.${path}`, value)
}
}
handleValidateEveryoneElse = (_ignore, path, value) => this.props.onValidate(path, value)
handleValidateOverride = (overrideIndex, path, value) =>
this.props.onValidate(`assignmentOverrides.nodes.${overrideIndex}.${path}`, value)
everyoneElseInvalidMessage = (_ignore, path) => this.props.invalidMessage(path)
invalidMessage = (overrideIndex, path) =>
this.props.invalidMessage(`assignmentOverrides.nodes.${overrideIndex}.${path}`)
renderEveryoneElse() {
if (this.props.assignment.dueAt !== null) {
return (
<EveryoneElse
assignment={this.props.assignment}
onChangeAssignment={this.props.onChangeAssignment}
readOnly={this.props.readOnly}
/>
)
}
return null
return (
<EveryoneElse
assignment={this.props.assignment}
onChangeAssignment={this.props.onChangeAssignment}
onValidate={this.handleValidateEveryoneElse}
invalidMessage={this.everyoneElseInvalidMessage}
readOnly={this.props.readOnly}
/>
)
}
renderOverrides() {
@ -74,6 +86,8 @@ export default class Overrides extends React.Component {
}}
index={index}
onChangeOverride={this.handleChangeOverride}
onValidate={this.handleValidateOverride}
invalidMessage={this.invalidMessage}
readOnly={this.props.readOnly}
/>
))
@ -85,7 +99,7 @@ export default class Overrides extends React.Component {
return (
<View as="div">
{this.renderOverrides()}
{this.renderEveryoneElse()}
{this.props.assignment.onlyVisibleToOverrides ? null : this.renderEveryoneElse()}
</View>
)
}

View File

@ -41,7 +41,14 @@ it("pulls everyone else's dates from the assignment", () => {
}
})
const {getByText} = render(<EveryoneElse assignment={assignment} onChangeAssignment={() => {}} />)
const {getByText} = render(
<EveryoneElse
assignment={assignment}
onChangeAssignment={() => {}}
onValidate={() => true}
invalidMessage={() => undefined}
/>
)
expect(getByText('Everyone else')).toBeInTheDocument()
const due = `Due: ${tz.format(aDueAt, I18n.t('#date.formats.full'))}`

View File

@ -21,25 +21,36 @@ import {render, fireEvent, waitForElement} from 'react-testing-library'
import {mockOverride} from '../../../test-utils'
import Override from '../Override'
it('renders an override', () => {
const override = mockOverride()
const {getByTestId} = render(
<Override override={override} onChangeOverride={() => {}} index={0} />
function renderOverride(override, props = {}) {
return render(
<Override
override={override}
onChangeOverride={() => {}}
index={0}
onValidate={() => true}
invalidMessage={() => undefined}
{...props}
/>
)
expect(getByTestId('OverrideSummary')).toBeInTheDocument()
})
}
it('displays OverrideDetail on expanding toggle group', async () => {
const override = mockOverride()
const {getByText, getByTestId} = render(
<Override override={override} onChangeOverride={() => {}} index={0} />
)
describe('Override', () => {
it('renders an override', () => {
const override = mockOverride()
const {getByTestId} = renderOverride(override)
expect(getByTestId('OverrideSummary')).toBeInTheDocument()
})
const expandButton = getByText('Expand')
fireEvent.click(expandButton)
// the detail is now rendered
const detail = await waitForElement(() => getByTestId('OverrideDetail'))
expect(detail).toBeInTheDocument()
// and the summary's still there
expect(getByTestId('OverrideSummary')).toBeInTheDocument()
it('displays OverrideDetail on expanding toggle group', async () => {
const override = mockOverride()
const {getByText, getByTestId} = renderOverride(override)
const expandButton = getByText('Click to show details')
fireEvent.click(expandButton)
// the detail is now rendered
const detail = await waitForElement(() => getByTestId('OverrideDetail'))
expect(detail).toBeInTheDocument()
// and the summary's still there
expect(getByTestId('OverrideSummary')).toBeInTheDocument()
})
})

View File

@ -17,27 +17,127 @@
*/
import React from 'react'
import {render} from 'react-testing-library'
import {mockOverride} from '../../../test-utils'
import {render, fireEvent, wait} from 'react-testing-library'
import {toLocaleString, browserTimeZone} from '@instructure/ui-i18n/lib/DateTime'
import {mockOverride, closest} from '../../../test-utils'
import OverrideDates from '../OverrideDates'
import I18n from 'i18n!assignments_2'
import tz from 'timezone'
const locale = 'en'
const timeZone = browserTimeZone()
it('renders readonly override dates', () => {
const override = mockOverride()
describe('OverrideDates', () => {
it('renders override dates', () => {
const override = mockOverride()
const {getByText} = render(
<OverrideDates dueAt={override.dueAt} unlockAt={override.unlockAt} lockAt={override.lockAt} />
)
const {getByText, getAllByTestId} = render(
<OverrideDates
dueAt={override.dueAt}
unlockAt={override.unlockAt}
lockAt={override.lockAt}
onChange={() => {}}
onValidate={() => true}
invalidMessage={() => undefined}
/>
)
const due = `${tz.format(override.dueAt, I18n.t('#date.formats.full'))}`
const available = `${tz.format(override.unlockAt, I18n.t('#date.formats.full'))}`
const until = `${tz.format(override.lockAt, I18n.t('#date.formats.full'))}`
expect(getByText('Due:')).toBeInTheDocument()
expect(getByText(due)).toBeInTheDocument()
expect(getByText('Available:')).toBeInTheDocument()
expect(getByText(available)).toBeInTheDocument()
expect(getByText('Until:')).toBeInTheDocument()
expect(getByText(until)).toBeInTheDocument()
expect(getAllByTestId('EditableDateTime')).toHaveLength(3)
expect(getByText('Due:')).toBeInTheDocument()
expect(getByText('Available:')).toBeInTheDocument()
expect(getByText('Until:')).toBeInTheDocument()
})
it('renders missing override dates', () => {
const override = mockOverride({unlockAt: null, lockAt: null})
const {getByText, getAllByTestId} = render(
<OverrideDates
dueAt={override.dueAt}
unlockAt={override.unlockAt}
lockAt={null}
onChange={() => {}}
onValidate={() => true}
invalidMessage={() => undefined}
/>
)
expect(getAllByTestId('EditableDateTime')).toHaveLength(3)
expect(getByText('No Until Date')).toBeInTheDocument()
})
// to get 100% test coverage
failADate('dueAt')
failADate('unlockAt')
failADate('lockAt')
})
function failADate(whichDate) {
const editButtonLabel = {
dueAt: 'Edit Due',
unlockAt: 'Edit Available',
lockAt: 'Edit Until'
}
const errMessages = {}
it(`renders the error message when ${whichDate} date is invalid`, async () => {
const override = mockOverride({
dueAt: '2018-12-25T23:59:59-05:00',
unlockAt: '2018-12-23T00:00:00-05:00',
lockAt: '2018-12-29T23:59:00-05:00'
})
// validate + invalidMessage mock the real deal
function validate(which, value) {
if (value < '2019-01-01T00:00:00-05:00') {
errMessages[which] = `${which} be bad`
return false
}
delete errMessages[which]
}
function invalidMessage(which) {
return errMessages[which]
}
const {container, getByText, getByDisplayValue, queryByTestId} = render(
<div>
<OverrideDates
dueAt={override.dueAt}
unlockAt={override.unlockAt}
lockAt={override.lockAt}
onChange={() => {}}
onValidate={validate}
invalidMessage={invalidMessage}
/>
<span id="focus-me" tabIndex="-1">
focus me
</span>
</div>
)
// click the edit button
const editDueBtn = closest(getByText(editButtonLabel[whichDate]), 'button')
editDueBtn.click()
const dateDisplay = toLocaleString(override[whichDate], locale, timeZone, 'LL')
let dinput
// wait for the popup
await wait(() => {
dinput = getByDisplayValue(dateDisplay)
})
// focus the date input and change it's value to a date that fails validation
dinput.focus()
fireEvent.change(dinput, {target: {value: '2019-01-02T00:00:00-05:00'}})
// blur the DateTimeInput to flip me to view mode.
container.querySelector('#focus-me').focus()
// wait for the popup to close
// (using test-utils' waitForNoElement, I get a
// "Warning: Can't perform a React state update on an unmounted component."
// though I cannot see the difference in the underlying logic between this
// and waitForNoElement)
await wait(() => {
expect(queryByTestId('EditableDateTime-editor')).toBeNull()
})
// the error message should be in the OverrideDates
expect(getByText(`${whichDate} be bad`)).toBeInTheDocument()
})
}

View File

@ -21,50 +21,60 @@ import {render} from 'react-testing-library'
import {mockOverride} from '../../../test-utils'
import OverrideDetail from '../OverrideDetail'
it('renders readonly override details', () => {
const override = mockOverride({
submissionTypes: ['online_text_entry', 'online_url', 'media_recording', 'online_upload']
function renderOD(override, props = {}) {
return render(
<OverrideDetail
override={override}
onChangeOverride={() => {}}
onValidate={() => true}
invalidMessage={() => undefined}
{...props}
/>
)
}
describe('OverrideDetail', () => {
it('renders readonly override details', () => {
const override = mockOverride({
submissionTypes: ['online_text_entry', 'online_url', 'media_recording', 'online_upload']
})
const {getByText, getByTestId} = renderOD(override, {readOnly: true})
// the labels
expect(getByText('Assign to:')).toBeInTheDocument()
expect(getByText('Due:')).toBeInTheDocument()
expect(getByText('Available:')).toBeInTheDocument()
expect(getByText('Until:')).toBeInTheDocument()
expect(getByText('Submission Type')).toBeInTheDocument()
expect(getByText('Attempts Allowed')).toBeInTheDocument()
expect(getByText('Score to keep')).toBeInTheDocument()
// the sub-components
expect(getByTestId('OverrideAssignTo')).toBeInTheDocument()
expect(getByTestId('OverrideDates')).toBeInTheDocument()
expect(getByTestId('OverrideSubmissionTypes')).toBeInTheDocument()
expect(getByTestId('OverrideAttempts-Detail')).toBeInTheDocument()
})
const {getByText, getByTestId} = render(
<OverrideDetail override={override} onChangeOverride={() => {}} readOnly />
)
it('renders editable override details', () => {
const override = mockOverride({
submissionTypes: ['online_text_entry', 'online_url', 'media_recording', 'online_upload']
})
// the labels
expect(getByText('Assign to:')).toBeInTheDocument()
expect(getByText('Due:')).toBeInTheDocument()
expect(getByText('Available:')).toBeInTheDocument()
expect(getByText('Until:')).toBeInTheDocument()
expect(getByText('Submission Type')).toBeInTheDocument()
expect(getByText('Attempts Allowed')).toBeInTheDocument()
expect(getByText('Score to keep')).toBeInTheDocument()
// the sub-components
expect(getByTestId('OverrideAssignTo')).toBeInTheDocument()
expect(getByTestId('OverrideDates')).toBeInTheDocument()
expect(getByTestId('OverrideSubmissionTypes')).toBeInTheDocument()
expect(getByTestId('OverrideAttempts-Detail')).toBeInTheDocument()
})
const {getByText, getByTestId} = renderOD(override)
it('renders editable override details', () => {
const override = mockOverride({
submissionTypes: ['online_text_entry', 'online_url', 'media_recording', 'online_upload']
// the labels
expect(getByText('Assign to:')).toBeInTheDocument()
expect(getByText('Due:')).toBeInTheDocument()
expect(getByText('Available:')).toBeInTheDocument()
expect(getByText('Until:')).toBeInTheDocument()
expect(getByText('Submission Type')).toBeInTheDocument()
expect(getByText('Attempts Allowed')).toBeInTheDocument()
expect(getByText('Score to keep')).toBeInTheDocument()
// the sub-components
expect(getByTestId('OverrideAssignTo')).toBeInTheDocument()
expect(getByTestId('OverrideDates')).toBeInTheDocument()
expect(getByTestId('OverrideSubmissionTypes')).toBeInTheDocument()
expect(getByTestId('OverrideAttempts-Detail')).toBeInTheDocument()
})
const {getByText, getByTestId} = render(
<OverrideDetail override={override} onChangeOverride={() => {}} />
)
// the labels
expect(getByText('Assign to:')).toBeInTheDocument()
expect(getByText('Due:')).toBeInTheDocument()
expect(getByText('Available:')).toBeInTheDocument()
expect(getByText('Until:')).toBeInTheDocument()
expect(getByText('Submission Type')).toBeInTheDocument()
expect(getByText('Attempts Allowed')).toBeInTheDocument()
expect(getByText('Score to keep')).toBeInTheDocument()
// the sub-components
expect(getByTestId('OverrideAssignTo')).toBeInTheDocument()
expect(getByTestId('OverrideDates')).toBeInTheDocument()
expect(getByTestId('OverrideSubmissionTypes')).toBeInTheDocument()
expect(getByTestId('OverrideAttempts-Detail')).toBeInTheDocument()
})

View File

@ -24,29 +24,84 @@ import {render} from 'react-testing-library'
import {mockOverride} from '../../../test-utils'
import OverrideSummary from '../OverrideSummary'
it('renders an OverrideSummary', () => {
const dueAt = '2018-11-27T13:00-0500'
const unlockAt = '2018-11-26T13:00-0500'
const lockAt = '2018-11-28T13:00-0500'
const override = mockOverride({
title: 'Section A',
dueAt,
unlockAt,
lockAt,
submissionTypes: ['online_upload', 'online_url'],
allowedAttempts: 1
describe('OverrideSummary', () => {
it('renders with unlock and lock dates', () => {
const dueAt = '2018-11-27T13:00-0500'
const unlockAt = '2018-11-26T13:00-0500'
const lockAt = '2018-11-28T13:00-0500'
const override = mockOverride({
title: 'Section A',
dueAt,
unlockAt,
lockAt,
submissionTypes: ['online_upload', 'online_url'],
allowedAttempts: 1
})
const {getByText, getByTestId} = render(<OverrideSummary override={override} />)
expect(getByTestId('OverrideAssignTo')).toBeInTheDocument()
expect(getByTestId('OverrideSubmissionTypes')).toBeInTheDocument()
const due = `Due: ${tz.format(dueAt, I18n.t('#date.formats.full'))}`
expect(getByText(due)).toBeInTheDocument()
const unlock = `${tz.format(unlockAt, I18n.t('#date.formats.short'))}`
const lock = `to ${tz.format(lockAt, I18n.t('#date.formats.full'))}`
expect(getByText(unlock)).toBeInTheDocument()
expect(getByText(lock)).toBeInTheDocument()
expect(getByTestId('OverrideAttempts-Summary')).toBeInTheDocument()
})
const {getByText, getByTestId} = render(<OverrideSummary override={override} />)
expect(getByTestId('OverrideAssignTo')).toBeInTheDocument()
expect(getByTestId('OverrideSubmissionTypes')).toBeInTheDocument()
const due = `Due: ${tz.format(dueAt, I18n.t('#date.formats.full'))}`
expect(getByText(due)).toBeInTheDocument()
it('renders with neither unlock or lock dates', () => {
const dueAt = '2018-11-27T13:00-0500'
const unlockAt = null
const lockAt = null
const override = mockOverride({
title: 'Section A',
dueAt,
unlockAt,
lockAt,
submissionTypes: ['online_upload', 'online_url'],
allowedAttempts: 1
})
const {getByText} = render(<OverrideSummary override={override} />)
const unlock = `${tz.format(unlockAt, I18n.t('#date.formats.short'))}`
const lock = `to ${tz.format(lockAt, I18n.t('#date.formats.full'))}`
expect(getByText(unlock)).toBeInTheDocument()
expect(getByText(lock)).toBeInTheDocument()
expect(getByText('Available')).toBeInTheDocument()
})
expect(getByTestId('OverrideAttempts-Summary')).toBeInTheDocument()
it('renders with only unlock date', () => {
const dueAt = '2018-11-27T13:00-0500'
const unlockAt = '2018-11-26T13:00-0500'
const lockAt = null
const override = mockOverride({
title: 'Section A',
dueAt,
unlockAt,
lockAt,
submissionTypes: ['online_upload', 'online_url'],
allowedAttempts: 1
})
const {getByText} = render(<OverrideSummary override={override} />)
const unlock = `${tz.format(unlockAt, I18n.t('#date.formats.full'))}`
expect(getByText(`Available after ${unlock}`)).toBeInTheDocument()
})
it('renders with only lock date', () => {
const dueAt = '2018-11-27T13:00-0500'
const unlockAt = null
const lockAt = '2018-11-28T13:00-0500'
const override = mockOverride({
title: 'Section A',
dueAt,
unlockAt,
lockAt,
submissionTypes: ['online_upload', 'online_url'],
allowedAttempts: 1
})
const {getByText} = render(<OverrideSummary override={override} />)
const lock = `${tz.format(lockAt, I18n.t('#date.formats.full'))}`
expect(getByText(`Available until ${lock}`)).toBeInTheDocument()
})
})

View File

@ -26,6 +26,9 @@ import get from 'lodash/get'
import set from 'lodash/set'
import {showFlashAlert} from 'jsx/shared/FlashAlert'
import ErrorBoundary from 'jsx/shared/components/ErrorBoundary'
import GenericErrorPage from 'jsx/shared/components/GenericErrorPage/index'
import errorShipUrl from '../../student/SVG/ErrorShip.svg'
import {Alert} from '@instructure/ui-alerts'
import {Mask} from '@instructure/ui-overlays'
@ -41,7 +44,9 @@ import TeacherFooter from './TeacherFooter'
import ConfirmDialog from './ConfirmDialog'
import MessageStudentsWhoDialog from './MessageStudentsWhoDialog'
import TeacherViewContext, {TeacherViewContextDefaults} from './TeacherViewContext'
import {validate} from '../Validators'
import AssignmentFieldValidator from '../AssignentFieldValidator'
const pathToOverrides = /assignmentOverrides\.nodes\.\d+/
export default class TeacherView extends React.Component {
static propTypes = {
@ -50,8 +55,7 @@ export default class TeacherView extends React.Component {
}
static defaultProps = {
// for now, put "#edit" in the URL and it will turn off readOnly
readOnly: window.location.hash.indexOf('edit') < 0
readOnly: window.location.hash.indexOf('readOnly') >= 0
}
constructor(props) {
@ -74,15 +78,26 @@ export default class TeacherView extends React.Component {
locale: (window.ENV && window.ENV.MOMENT_LOCALE) || TeacherViewContextDefaults.locale,
timeZone: (window.ENV && window.ENV.TIMEZONE) || TeacherViewContextDefaults.timeZone
}
this.fieldValidator = new AssignmentFieldValidator()
}
componentDidMount() {
window.addEventListener('beforeunload', this.handleBeforeUnload)
}
handleBeforeUnload = event => {
if (this.state.isDirty) {
event.preventDefault()
event.returnValue = ''
}
}
// @param value: the new value. may be a scalar, an array, or an object.
// @param path: where w/in the assignment it should go. May be a string representing
// the dot-separated path (e.g. 'assignmentOverrides.nodes.0`) or an array
// of steps (e.g. ['assignmentOverrides', 'nodes', 0] (which could also be '0'))
// @param path: where w/in the assignment it should go as a string representing
// the dot-separated path (e.g. 'assignmentOverrides.nodes.0`) to the value
updateWorkingAssignment(path, value) {
const dottedpath = Array.isArray(path) ? path.join('.') : path
const old = get(this.state.workingAssignment, dottedpath)
const old = get(this.state.workingAssignment, path)
if (old === value) {
return this.state.workingAssignment
}
@ -95,19 +110,8 @@ export default class TeacherView extends React.Component {
// add or remove path from the set of invalid fields based on the new value
// returns the updated set
updateInvalids(path, value) {
const isValid = this.validate(path, value)
if (isValid) {
if (this.state.invalids[path]) {
const invalids = {...this.state.invalids}
delete invalids[path]
return invalids
}
} else if (!this.state.invalids[path]) {
const invalids = {...this.state.invalids}
invalids[path] = true
return invalids
}
return this.state.invalids
this.validate(path, value)
return this.fieldValidator.invalidFields()
}
// if the new value is different from the existing value, update TeacherView's react state
@ -125,11 +129,21 @@ export default class TeacherView extends React.Component {
}
// validate the value at the path
// if path points into an override, extract the override from the assignment
// and pass it as the context in which to validate the value
validate = (path, value) => {
const isValid = validate(path, value)
let context = this.state.workingAssignment
const match = pathToOverrides.exec(path)
if (match) {
// extract the override
context = get(this.state.workingAssignment, match[0])
}
const isValid = this.fieldValidator.validate(path, value, context)
return isValid
}
invalidMessage = path => this.fieldValidator.errorMessage(path)
isAssignmentValid = () => Object.keys(this.state.invalids).length === 0
handleUnsubmittedClick = () => {
@ -175,7 +189,15 @@ export default class TeacherView extends React.Component {
}
handleCancel = () => {
this.setState({workingAssignment: this.props.assignment, isDirty: false, invalids: {}})
this.setState((state, props) => {
// revalidate the current invalid fields with the original values
Object.keys(state.invalids).forEach(path => this.validate(path, get(props.assignment, path)))
return {
workingAssignment: this.props.assignment,
isDirty: false,
invalids: this.fieldValidator.invalidFields()
}
})
}
handleSave(saveAssignment) {
@ -188,8 +210,10 @@ export default class TeacherView extends React.Component {
name: assignment.name,
description: assignment.description,
state: assignment.state,
// ,dueAt: assignment.due_at,
pointsPossible: parseFloat(assignment.pointsPossible)
pointsPossible: parseFloat(assignment.pointsPossible),
dueAt: assignment.dueAt,
unlockAt: assignment.unlockAt,
lockAt: assignment.lockAt
}
})
})
@ -327,52 +351,63 @@ export default class TeacherView extends React.Component {
const assignment = this.state.workingAssignment
const clazz = classnames('assignments-teacher', {dirty})
return (
<TeacherViewContext.Provider value={this.contextValue}>
<div className={clazz}>
{this.renderDeleteDialog()}
<ScreenReaderContent>
<h1>{assignment.name}</h1>
</ScreenReaderContent>
<Mutation
mutation={SAVE_ASSIGNMENT}
onCompleted={this.handleSaveSuccess}
onError={this.handleSaveError}
>
{saveAssignment => (
<React.Fragment>
<Header
assignment={assignment}
onChangeAssignment={this.handleChangeAssignment}
onValidate={this.validate}
onSetWorkstate={newState => this.handleSetWorkstate(saveAssignment, newState)}
onUnsubmittedClick={this.handleUnsubmittedClick}
onDelete={this.handleDeleteButtonPressed}
readOnly={this.props.readOnly}
/>
<ContentTabs
assignment={assignment}
onChangeAssignment={this.handleChangeAssignment}
onValidate={this.validate}
readOnly={this.props.readOnly}
/>
{this.renderMessageStudentsWhoDialog()}
{dirty || !this.isAssignmentValid() ? (
<TeacherFooter
onCancel={this.handleCancel}
onSave={() => this.handleSave(saveAssignment)}
onPublish={() => this.handlePublish(saveAssignment)}
<ErrorBoundary
errorComponent={
<GenericErrorPage
imageUrl={errorShipUrl}
errorCategory={I18n.t('Assignments 2 Teacher View Error Page')}
/>
}
>
<TeacherViewContext.Provider value={this.contextValue}>
<div className={clazz}>
{this.renderDeleteDialog()}
<ScreenReaderContent>
<h1>{assignment.name}</h1>
</ScreenReaderContent>
<Mutation
mutation={SAVE_ASSIGNMENT}
onCompleted={this.handleSaveSuccess}
onError={this.handleSaveError}
>
{saveAssignment => (
<React.Fragment>
<Header
assignment={assignment}
onChangeAssignment={this.handleChangeAssignment}
onValidate={this.validate}
invalidMessage={this.invalidMessage}
onSetWorkstate={newState => this.handleSetWorkstate(saveAssignment, newState)}
onUnsubmittedClick={this.handleUnsubmittedClick}
onDelete={this.handleDeleteButtonPressed}
readOnly={this.props.readOnly}
/>
) : null}
</React.Fragment>
)}
</Mutation>
<Portal open={this.state.isSaving}>
<Mask fullscreen>
<Spinner size="large" title={I18n.t('Saving assignment')} />
</Mask>
</Portal>
</div>
</TeacherViewContext.Provider>
<ContentTabs
assignment={assignment}
onChangeAssignment={this.handleChangeAssignment}
onValidate={this.validate}
invalidMessage={this.invalidMessage}
readOnly={this.props.readOnly}
/>
{this.renderMessageStudentsWhoDialog()}
{dirty || !this.isAssignmentValid() ? (
<TeacherFooter
onCancel={this.handleCancel}
onSave={() => this.handleSave(saveAssignment)}
onPublish={() => this.handlePublish(saveAssignment)}
/>
) : null}
</React.Fragment>
)}
</Mutation>
<Portal open={this.state.isSaving}>
<Mask fullscreen>
<Spinner size="large" title={I18n.t('Saving assignment')} />
</Mask>
</Portal>
</div>
</TeacherViewContext.Provider>
</ErrorBoundary>
)
}
}

View File

@ -54,6 +54,7 @@ export default class Toolbox extends React.Component {
assignment: TeacherAssignmentShape.isRequired,
onChangeAssignment: func.isRequired,
onValidate: func.isRequired,
invalidMessage: func.isRequired,
onSetWorkstate: func.isRequired,
onUnsubmittedClick: func,
onDelete: func,
@ -166,6 +167,7 @@ export default class Toolbox extends React.Component {
onChange={this.handlePointsChange}
onChangeMode={this.handlePointsChangeMode}
onValidate={this.props.onValidate}
invalidMessage={this.props.invalidMessage}
readOnly={this.props.readOnly}
/>
)

View File

@ -27,6 +27,7 @@ it('renders', () => {
assignment={mockAssignment()}
onChangeAssignment={() => {}}
onValidate={() => true}
invalidMessage={() => undefined}
/>
)
expect(container.querySelectorAll('[role="tab"]')).toHaveLength(4)

View File

@ -30,6 +30,7 @@ function renderDetails(assignment, props = {}) {
assignment={assignment}
onChangeAssignment={() => {}}
onValidate={() => true}
invalidMessage={() => undefined}
{...props}
/>
)
@ -67,7 +68,7 @@ describe('Assignent Details', () => {
expect(getByText('Section A')).toBeInTheDocument()
expect(getByText('Section B')).toBeInTheDocument()
expect(queryAllByText('Everyone', {exact: false})).toHaveLength(0)
expect(queryAllByText('Everyone', {exact: false})).toHaveLength(1)
})
it('renders the Add Override button if !readOnly', () => {

View File

@ -32,6 +32,7 @@ describe('assignments 2 teacher view header', () => {
onChangeAssignment={() => {}}
onSetWorkstate={() => {}}
onValidate={() => true}
invalidMessage={() => undefined}
/>
</MockedProvider>
)

View File

@ -30,6 +30,7 @@ function renderToolbox(assignment) {
onChangeAssignment={() => {}}
onSetWorkstate={() => {}}
onValidate={() => true}
invalidMessage={() => undefined}
/>
</MockedProvider>
)

View File

@ -135,6 +135,7 @@ export function mockAssignment(overrides) {
submissionTypes: ['online_text_entry'],
allowedExtensions: [],
allowedAttempts: null,
onlyVisibleToOverrides: false,
assignmentOverrides: {
pageInfo: mockPageInfo(),
nodes: []

View File

@ -84,7 +84,7 @@ class OverrideListPresenter
# Returns an array of due date hashes.
def visible_due_dates
return [] unless assignment
assignment.dates_hash_visible_to(user).each do |due_date|
due_date[:raw] = due_date.dup
due_date[:lock_at] = lock_at due_date