Fix up <Select> in gradebook history for InstUI 7
Closes FOO-1107 flag=none Another update to a component using features in the version 6 <Select> component. CanvasAsyncSelect seemed to work pretty nicely, although it needed a couple of features added as well to round out the functionality. Test plan: * Go to courses/:id/gradebook/history * The filter boxes should work like they always have Change-Id: I3cd015a4edac4fa608d01158f9f320642f78a144 Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/250903 Reviewed-by: Ed Schiebel <eschiebel@instructure.com> Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com> Product-Review: Charley Kline <ckline@instructure.com> QA-Review: August Thornton <august@instructure.com>
This commit is contained in:
parent
12eb84db7b
commit
c730122179
|
@ -22,8 +22,9 @@ import {arrayOf, func, shape, string} from 'prop-types'
|
|||
import I18n from 'i18n!gradebook_history'
|
||||
import tz from 'timezone'
|
||||
import moment from 'moment'
|
||||
import {debounce} from 'lodash'
|
||||
import {Checkbox} from '@instructure/ui-checkbox'
|
||||
import {Select} from '@instructure/ui-forms'
|
||||
import CanvasAsyncSelect from 'jsx/shared/components/CanvasAsyncSelect'
|
||||
import {Button} from '@instructure/ui-buttons'
|
||||
import {View, Grid} from '@instructure/ui-layout'
|
||||
import {FormFieldGroup} from '@instructure/ui-form-field'
|
||||
|
@ -33,6 +34,8 @@ import {showFlashAlert} from '../shared/FlashAlert'
|
|||
import environment from './environment'
|
||||
import CanvasDateInput from 'jsx/shared/components/CanvasDateInput'
|
||||
|
||||
const DEBOUNCE_DELAY = 500 // milliseconds
|
||||
|
||||
const recordShape = shape({
|
||||
fetchStatus: string.isRequired,
|
||||
items: arrayOf(
|
||||
|
@ -58,20 +61,24 @@ class SearchFormComponent extends Component {
|
|||
getSearchOptionsNextPage: func.isRequired
|
||||
}
|
||||
|
||||
state = {
|
||||
selected: {
|
||||
assignment: '',
|
||||
grader: '',
|
||||
student: '',
|
||||
from: {value: ''},
|
||||
to: {value: ''},
|
||||
showFinalGradeOverridesOnly: false
|
||||
},
|
||||
messages: {
|
||||
assignments: I18n.t('Type a few letters to start searching'),
|
||||
graders: I18n.t('Type a few letters to start searching'),
|
||||
students: I18n.t('Type a few letters to start searching')
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.state = {
|
||||
selected: {
|
||||
assignment: '',
|
||||
grader: '',
|
||||
student: '',
|
||||
from: {value: ''},
|
||||
to: {value: ''},
|
||||
showFinalGradeOverridesOnly: false
|
||||
},
|
||||
messages: {
|
||||
assignments: I18n.t('Type a few letters to start searching'),
|
||||
graders: I18n.t('Type a few letters to start searching'),
|
||||
students: I18n.t('Type a few letters to start searching')
|
||||
}
|
||||
}
|
||||
this.debouncedGetSearchOptions = debounce(props.getSearchOptions, DEBOUNCE_DELAY)
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
|
@ -150,17 +157,18 @@ class SearchFormComponent extends Component {
|
|||
}))
|
||||
}
|
||||
|
||||
setSelectedAssignment = (event, selectedOption) => {
|
||||
this.props.clearSearchOptions('assignments')
|
||||
setSelectedAssignment = (_event, selectedOption) => {
|
||||
const selname = this.props.assignments.items.find(e => e.id === selectedOption)?.name
|
||||
if (selname) this.props.getSearchOptions('assignments', selname)
|
||||
this.setState(prevState => {
|
||||
const selected = {
|
||||
...prevState.selected,
|
||||
assignment: selectedOption ? selectedOption.id : ''
|
||||
assignment: selectedOption || ''
|
||||
}
|
||||
|
||||
// If we selected an assignment, uncheck the "show final grade overrides
|
||||
// only" checkbox
|
||||
if (selectedOption != null) {
|
||||
if (selectedOption) {
|
||||
selected.showFinalGradeOverridesOnly = false
|
||||
}
|
||||
|
||||
|
@ -168,22 +176,24 @@ class SearchFormComponent extends Component {
|
|||
})
|
||||
}
|
||||
|
||||
setSelectedGrader = (event, selected) => {
|
||||
this.props.clearSearchOptions('graders')
|
||||
setSelectedGrader = (_event, selected) => {
|
||||
const selname = this.props.graders.items.find(e => e.id === selected)?.name
|
||||
if (selname) this.props.getSearchOptions('graders', selname)
|
||||
this.setState(prevState => ({
|
||||
selected: {
|
||||
...prevState.selected,
|
||||
grader: selected ? selected.id : ''
|
||||
grader: selected || ''
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
setSelectedStudent = (event, selected) => {
|
||||
this.props.clearSearchOptions('students')
|
||||
setSelectedStudent = (_event, selected) => {
|
||||
const selname = this.props.students.items.find(e => e.id === selected)?.name
|
||||
if (selname) this.props.getSearchOptions('students', selname)
|
||||
this.setState(prevState => ({
|
||||
selected: {
|
||||
...prevState.selected,
|
||||
student: selected ? selected.id : ''
|
||||
student: selected || ''
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
@ -245,21 +255,13 @@ class SearchFormComponent extends Component {
|
|||
this.props.clearSearchOptions('assignments')
|
||||
}
|
||||
|
||||
this.setState(
|
||||
prevState => ({
|
||||
selected: {
|
||||
...prevState.selected,
|
||||
assignment: enabled ? '' : prevState.selected.assignment,
|
||||
showFinalGradeOverridesOnly: enabled
|
||||
}
|
||||
}),
|
||||
() => {
|
||||
if (enabled) {
|
||||
// Also manually clear the contents of the assignment select input
|
||||
this.assignmentInput.value = ''
|
||||
}
|
||||
this.setState(prevState => ({
|
||||
selected: {
|
||||
...prevState.selected,
|
||||
assignment: enabled ? '' : prevState.selected.assignment,
|
||||
showFinalGradeOverridesOnly: enabled
|
||||
}
|
||||
)
|
||||
}))
|
||||
}
|
||||
|
||||
handleSearchEntry = (target, searchTerm) => {
|
||||
|
@ -272,23 +274,18 @@ class SearchFormComponent extends Component {
|
|||
return
|
||||
}
|
||||
|
||||
this.props.getSearchOptions(target, searchTerm)
|
||||
this.debouncedGetSearchOptions(target, searchTerm)
|
||||
}
|
||||
|
||||
handleSubmit = () => {
|
||||
this.props.getGradebookHistory(this.state.selected)
|
||||
}
|
||||
|
||||
filterNone = options =>
|
||||
// empty function here as the default filter function for Select
|
||||
// does a startsWith call, and won't match `nora` -> `Elenora` for example
|
||||
options
|
||||
|
||||
renderAsOptions = data =>
|
||||
data.map(item => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
data.map(i => (
|
||||
<CanvasAsyncSelect.Option key={i.id} id={i.id}>
|
||||
{i.name}
|
||||
</CanvasAsyncSelect.Option>
|
||||
))
|
||||
|
||||
render() {
|
||||
|
@ -313,63 +310,42 @@ class SearchFormComponent extends Component {
|
|||
vAlign="top"
|
||||
startAt="medium"
|
||||
>
|
||||
<Select
|
||||
editable
|
||||
<CanvasAsyncSelect
|
||||
id="students"
|
||||
allowEmpty
|
||||
emptyOption={this.state.messages.students}
|
||||
filter={this.filterNone}
|
||||
label={I18n.t('Student')}
|
||||
loadingText={
|
||||
this.props.students.fetchStatus === 'started'
|
||||
? I18n.t('Loading Students')
|
||||
: undefined
|
||||
}
|
||||
renderLabel={I18n.t('Student')}
|
||||
isLoading={this.props.students.fetchStatus === 'started'}
|
||||
selectedOptionId={this.state.selected.student}
|
||||
noOptionsLabel={this.state.messages.students}
|
||||
onBlur={this.promptUserEntry}
|
||||
onChange={this.setSelectedStudent}
|
||||
onOptionSelected={this.setSelectedStudent}
|
||||
onInputChange={this.handleStudentChange}
|
||||
>
|
||||
{this.renderAsOptions(this.props.students.items)}
|
||||
</Select>
|
||||
<Select
|
||||
editable
|
||||
</CanvasAsyncSelect>
|
||||
<CanvasAsyncSelect
|
||||
id="graders"
|
||||
allowEmpty
|
||||
emptyOption={this.state.messages.graders}
|
||||
filter={this.filterNone}
|
||||
label={I18n.t('Grader')}
|
||||
loadingText={
|
||||
this.props.graders.fetchStatus === 'started'
|
||||
? I18n.t('Loading Graders')
|
||||
: undefined
|
||||
}
|
||||
renderLabel={I18n.t('Grader')}
|
||||
isLoading={this.props.graders.fetchStatus === 'started'}
|
||||
selectedOptionId={this.state.selected.grader}
|
||||
noOptionsLabel={this.state.messages.graders}
|
||||
onBlur={this.promptUserEntry}
|
||||
onChange={this.setSelectedGrader}
|
||||
onOptionSelected={this.setSelectedGrader}
|
||||
onInputChange={this.handleGraderChange}
|
||||
>
|
||||
{this.renderAsOptions(this.props.graders.items)}
|
||||
</Select>
|
||||
<Select
|
||||
editable
|
||||
</CanvasAsyncSelect>
|
||||
<CanvasAsyncSelect
|
||||
id="assignments"
|
||||
allowEmpty
|
||||
emptyOption={this.state.messages.assignments}
|
||||
filter={this.filterNone}
|
||||
inputRef={ref => {
|
||||
this.assignmentInput = ref
|
||||
}}
|
||||
label={I18n.t('Artifact')}
|
||||
loadingText={
|
||||
this.props.assignments.fetchStatus === 'started'
|
||||
? I18n.t('Loading Artifact')
|
||||
: undefined
|
||||
}
|
||||
renderLabel={I18n.t('Artifact')}
|
||||
isLoading={this.props.assignments.fetchStatus === 'started'}
|
||||
selectedOptionId={this.state.selected.assignment}
|
||||
noOptionsLabel={this.state.messages.assignments}
|
||||
onBlur={this.promptUserEntry}
|
||||
onChange={this.setSelectedAssignment}
|
||||
onOptionSelected={this.setSelectedAssignment}
|
||||
onInputChange={this.handleAssignmentChange}
|
||||
>
|
||||
{this.renderAsOptions(this.props.assignments.items)}
|
||||
</Select>
|
||||
</CanvasAsyncSelect>
|
||||
</FormFieldGroup>
|
||||
|
||||
<FormFieldGroup
|
||||
|
|
|
@ -0,0 +1,174 @@
|
|||
/* Copyright (C) 2020 - 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 MockDate from 'mockdate'
|
||||
import {render, act, fireEvent} from '@testing-library/react'
|
||||
import {SearchFormComponent as Subject} from '../SearchForm'
|
||||
|
||||
function defaultProps() {
|
||||
return {
|
||||
fetchHistoryStatus: 'started',
|
||||
getGradebookHistory: Function.prototype,
|
||||
clearSearchOptions: Function.prototype,
|
||||
getSearchOptions: Function.prototype,
|
||||
getSearchOptionsNextPage: Function.prototype,
|
||||
assignments: {
|
||||
fetchStatus: 'started',
|
||||
items: [],
|
||||
nextPage: ''
|
||||
},
|
||||
graders: {
|
||||
fetchStatus: 'started',
|
||||
items: [],
|
||||
nextPage: ''
|
||||
},
|
||||
students: {
|
||||
fetchStatus: 'started',
|
||||
items: [],
|
||||
nextPage: ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fields = ['assignments', 'graders', 'students']
|
||||
|
||||
function mountSubject(props = {}) {
|
||||
return render(<Subject {...defaultProps()} {...props} />)
|
||||
}
|
||||
|
||||
let liveRegion = null
|
||||
beforeAll(() => {
|
||||
if (!document.getElementById('flash_screenreader_holder')) {
|
||||
liveRegion = document.createElement('div')
|
||||
liveRegion.id = 'flash_screenreader_holder'
|
||||
liveRegion.setAttribute('role', 'alert')
|
||||
document.body.appendChild(liveRegion)
|
||||
}
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
if (liveRegion) liveRegion.remove()
|
||||
})
|
||||
|
||||
describe('GradebookHistory::SearchFormComponent', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
MockDate.reset()
|
||||
})
|
||||
|
||||
function advance(msec) {
|
||||
act(() => {
|
||||
const now = Date.now()
|
||||
MockDate.set(now + msec)
|
||||
jest.advanceTimersByTime(msec)
|
||||
})
|
||||
}
|
||||
|
||||
it('displays a flash alert on fetch failure', () => {
|
||||
const {rerender} = mountSubject()
|
||||
let flash = document.getElementById('flashalert_message_holder')
|
||||
expect(flash).toBeNull()
|
||||
rerender(<Subject {...defaultProps()} fetchHistoryStatus="failure" />)
|
||||
flash = document.getElementById('flashalert_message_holder')
|
||||
expect(flash).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('properly debounces getSearchOptions calls', () => {
|
||||
const getSearchOptions = jest.fn()
|
||||
const {container} = mountSubject({getSearchOptions})
|
||||
const input = container.querySelector('input#assignments')
|
||||
fireEvent.click(input)
|
||||
fireEvent.input(input, {target: {id: 'assignments', value: 'onetwo'}})
|
||||
expect(getSearchOptions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('calls getSearchOptions with correct arguments after more than two letters are typed', () => {
|
||||
fields.forEach(field => {
|
||||
it(`for ${field}`, () => {
|
||||
const getSearchOptions = jest.fn()
|
||||
const {container} = mountSubject({getSearchOptions})
|
||||
const input = container.querySelector(`input#${field}`)
|
||||
fireEvent.click(input)
|
||||
fireEvent.input(input, {target: {id: field, value: 'onetwo'}})
|
||||
advance(1000) // wait for debounce
|
||||
expect(getSearchOptions).toHaveBeenCalledWith(field, 'onetwo')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('hits clearSearchOptions after two or fewer letters are typed', () => {
|
||||
fields.forEach(field => {
|
||||
it(`for ${field}`, () => {
|
||||
const clearSearchOptions = jest.fn()
|
||||
const {container} = mountSubject({
|
||||
clearSearchOptions,
|
||||
[field]: {
|
||||
fetchStatus: 'success',
|
||||
items: [{id: '1', name: `One of the ${field}`}],
|
||||
nextPage: ''
|
||||
}
|
||||
})
|
||||
const input = container.querySelector(`input#${field}`)
|
||||
fireEvent.click(input)
|
||||
fireEvent.input(input, {target: {id: field, value: 'xy'}})
|
||||
expect(clearSearchOptions).toHaveBeenCalledWith(field)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('displays correct "not found" message when no search records are returned', () => {
|
||||
fields.forEach(field => {
|
||||
it(`for ${field}`, () => {
|
||||
// slight mismatch between prop name and what's displayed, here...
|
||||
const displayField = field === 'assignments' ? 'artifacts' : field
|
||||
const {rerender, container, getByText} = mountSubject()
|
||||
const results = {
|
||||
[field]: {fetchStatus: 'success', items: [], nextPage: ''}
|
||||
}
|
||||
rerender(<Subject {...defaultProps()} {...results} />)
|
||||
const input = container.querySelector(`input#${field}`)
|
||||
fireEvent.click(input)
|
||||
expect(getByText(`No ${displayField} with that name found`)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('calls getSearchOptionsNextPage if there are more items to load', () => {
|
||||
const getSearchOptionsNextPage = jest.fn()
|
||||
const {rerender} = mountSubject({getSearchOptionsNextPage})
|
||||
expect(getSearchOptionsNextPage).not.toHaveBeenCalled()
|
||||
rerender(
|
||||
<Subject
|
||||
getSearchOptionsNextPage={getSearchOptionsNextPage}
|
||||
{...defaultProps()}
|
||||
students={{
|
||||
fetchStatus: 'success',
|
||||
items: [],
|
||||
nextPage: 'https://nextpage.example.com'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
expect(getSearchOptionsNextPage).toHaveBeenCalledWith(
|
||||
'students',
|
||||
'https://nextpage.example.com'
|
||||
)
|
||||
})
|
||||
})
|
|
@ -33,16 +33,26 @@ CanvasAsyncSelect.propTypes = {
|
|||
isLoading: bool,
|
||||
selectedOptionId: string,
|
||||
noOptionsLabel: string,
|
||||
noOptionsValue: string, // value for selected option when no or invalid selection
|
||||
onOptionSelected: func, // (event, optionId | null) => {}
|
||||
onInputChange: func // (event, value) => {}
|
||||
onInputChange: func, // (event, value) => {}
|
||||
onBlur: func,
|
||||
onFocus: func
|
||||
}
|
||||
|
||||
// NOTE:
|
||||
// If the inputValue prop is not specified, this component will control the inputValue
|
||||
// of <Select> itself. If it is specified, it is the caller's responsibility to manage
|
||||
// its value in response to input changes!
|
||||
|
||||
CanvasAsyncSelect.defaultProps = {
|
||||
inputValue: '',
|
||||
isLoading: false,
|
||||
noOptionsLabel: '---',
|
||||
onOptionSelected: () => {},
|
||||
onInputChange: () => {}
|
||||
noOptionsValue: '',
|
||||
onOptionSelected: Function.prototype,
|
||||
onInputChange: Function.prototype,
|
||||
onBlur: Function.prototype,
|
||||
onFocus: Function.prototype
|
||||
}
|
||||
|
||||
export default function CanvasAsyncSelect({
|
||||
|
@ -51,17 +61,23 @@ export default function CanvasAsyncSelect({
|
|||
isLoading,
|
||||
selectedOptionId,
|
||||
noOptionsLabel,
|
||||
noOptionsValue,
|
||||
onOptionSelected,
|
||||
onInputChange,
|
||||
onFocus,
|
||||
onBlur,
|
||||
children,
|
||||
...selectProps
|
||||
}) {
|
||||
const previousLoadingRef = useRef(isLoading)
|
||||
const previousLoading = previousLoadingRef.current
|
||||
const previousSelectedOptionIdRef = useRef(selectedOptionId)
|
||||
|
||||
const [isShowingOptions, setIsShowingOptions] = useState(false)
|
||||
const [highlightedOptionId, setHighlightedOptionId] = useState(null)
|
||||
const [announcement, setAnnouncement] = useState('')
|
||||
const [hasFocus, setHasFocus] = useState(false)
|
||||
const [managedInputValue, setManagedInputValue] = useState('')
|
||||
|
||||
function findOptionById(id) {
|
||||
let option
|
||||
|
@ -88,7 +104,7 @@ export default function CanvasAsyncSelect({
|
|||
if (isLoading) {
|
||||
return (
|
||||
<Select.Option id={noOptionsId}>
|
||||
<Spinner renderTitle={I18n.t('Loading options...')} size="small" />
|
||||
<Spinner renderTitle={I18n.t('Loading options...')} size="x-small" />
|
||||
</Select.Option>
|
||||
)
|
||||
} else if (React.Children.count(children) === 0) {
|
||||
|
@ -101,6 +117,7 @@ export default function CanvasAsyncSelect({
|
|||
function handleInputChange(ev) {
|
||||
// user typing in the input negates the selection
|
||||
const newValue = ev.target.value
|
||||
if (typeof inputValue === 'undefined') setManagedInputValue(newValue)
|
||||
setIsShowingOptions(true)
|
||||
onInputChange(ev, newValue)
|
||||
}
|
||||
|
@ -124,23 +141,36 @@ export default function CanvasAsyncSelect({
|
|||
|
||||
function handleSelectOption(ev, {id}) {
|
||||
const selectedOption = findOptionById(id)
|
||||
const selectedText = selectedOption?.props.children
|
||||
if (!selectedOption) return
|
||||
setIsShowingOptions(false)
|
||||
setAnnouncement(
|
||||
<>
|
||||
{I18n.t('Option selected:')} {selectedOption.props.children} {I18n.t('List collapsed.')}
|
||||
{I18n.t('Option selected:')} {selectedText} {I18n.t('List collapsed.')}
|
||||
</>
|
||||
)
|
||||
if (id !== noOptionsId) onOptionSelected(ev, id)
|
||||
if (id !== noOptionsId) {
|
||||
if (typeof inputValue === 'undefined') setManagedInputValue(selectedText)
|
||||
onOptionSelected(ev, id)
|
||||
}
|
||||
}
|
||||
|
||||
function handleFocus() {
|
||||
function handleFocus(ev) {
|
||||
setHasFocus(true)
|
||||
onFocus(ev)
|
||||
}
|
||||
|
||||
function handleBlur() {
|
||||
function handleBlur(ev) {
|
||||
setHasFocus(false)
|
||||
setHighlightedOptionId(null)
|
||||
// if we're managing our own input value and all our possible options just
|
||||
// went away, be sure to notify the parent the the selection has gone away
|
||||
// as well.
|
||||
if (React.Children.count(children) === 0 && typeof inputValue === 'undefined') {
|
||||
setManagedInputValue('')
|
||||
onOptionSelected(ev, noOptionsValue)
|
||||
}
|
||||
onBlur(ev)
|
||||
}
|
||||
|
||||
if (hasFocus && previousLoading !== isLoading) {
|
||||
|
@ -151,8 +181,18 @@ export default function CanvasAsyncSelect({
|
|||
}
|
||||
}
|
||||
|
||||
// If we're controlling our own inputValue, take care to clear it if the selection resets
|
||||
// out from under us.
|
||||
if (
|
||||
typeof inputValue === 'undefined' &&
|
||||
selectedOptionId !== previousSelectedOptionIdRef.current &&
|
||||
!selectedOptionId
|
||||
) {
|
||||
setManagedInputValue('')
|
||||
}
|
||||
|
||||
const controlledProps = {
|
||||
inputValue,
|
||||
inputValue: typeof inputValue === 'undefined' ? managedInputValue : inputValue,
|
||||
isShowingOptions,
|
||||
assistiveText: I18n.t('Type to search'),
|
||||
onFocus: handleFocus,
|
||||
|
@ -167,6 +207,7 @@ export default function CanvasAsyncSelect({
|
|||
// remember previous isLoading value so we know whether we need to send announcements
|
||||
// (we can't use an effect for this because effects only run after the DOM changes)
|
||||
previousLoadingRef.current = isLoading
|
||||
previousSelectedOptionIdRef.current = selectedOptionId
|
||||
return (
|
||||
<>
|
||||
<Select {...controlledProps} {...selectProps}>
|
||||
|
|
|
@ -21,8 +21,8 @@ import {mount, shallow} from 'enzyme'
|
|||
import {SearchFormComponent} from 'jsx/gradebook-history/SearchForm'
|
||||
import {Button} from '@instructure/ui-buttons'
|
||||
import CanvasDateInput from 'jsx/shared/components/CanvasDateInput'
|
||||
import CanvasAsyncSelect from 'jsx/shared/components/CanvasAsyncSelect'
|
||||
import {FormFieldGroup} from '@instructure/ui-form-field'
|
||||
import {destroyContainer} from 'jsx/shared/FlashAlert'
|
||||
import Fixtures from './Fixtures'
|
||||
import fakeENV from 'helpers/fakeENV'
|
||||
|
||||
|
@ -49,6 +49,11 @@ const defaultProps = () => ({
|
|||
}
|
||||
})
|
||||
|
||||
const liveRegion = document.createElement('div')
|
||||
liveRegion.id = 'flash_screenreader_holder'
|
||||
liveRegion.setAttribute('role', 'alert')
|
||||
document.body.appendChild(liveRegion)
|
||||
|
||||
const mountComponent = (props = {}) =>
|
||||
shallow(<SearchFormComponent {...defaultProps()} {...props} />)
|
||||
|
||||
|
@ -69,19 +74,19 @@ test('has a form field group', function() {
|
|||
test('has an Autocomplete with id #graders', function() {
|
||||
const input = this.wrapper.find('#graders')
|
||||
equal(input.length, 1)
|
||||
ok(input.is('Select'))
|
||||
ok(input.is(CanvasAsyncSelect))
|
||||
})
|
||||
|
||||
test('has an Autocomplete with id #students', function() {
|
||||
const input = this.wrapper.find('#students')
|
||||
equal(input.length, 1)
|
||||
ok(input.is('Select'))
|
||||
ok(input.is(CanvasAsyncSelect))
|
||||
})
|
||||
|
||||
test('has an Autocomplete with id #assignments', function() {
|
||||
const input = this.wrapper.find('#assignments')
|
||||
equal(input.length, 1)
|
||||
ok(input.is('Select'))
|
||||
ok(input.is(CanvasAsyncSelect))
|
||||
})
|
||||
|
||||
test('has date pickers for from date and to date', function() {
|
||||
|
@ -200,125 +205,9 @@ test('dispatches with the state of input', function() {
|
|||
)
|
||||
})
|
||||
|
||||
QUnit.module('SearchForm fetchHistoryStatus prop', {
|
||||
setup() {
|
||||
this.wrapper = mountComponent({fetchHistoryStatus: 'started'})
|
||||
},
|
||||
|
||||
teardown() {
|
||||
this.wrapper.unmount()
|
||||
destroyContainer()
|
||||
}
|
||||
})
|
||||
|
||||
test('turning from started to failure displays an AjaxFlashAlert', function() {
|
||||
// the container the alerts get rendered into doesn't exist until ajaxFlashAlert needs it
|
||||
// and then it'll create it itself, appending the error message into this new container
|
||||
equal(document.getElementById('flash_message_holder'), null)
|
||||
this.wrapper.setProps({fetchHistoryStatus: 'failure'})
|
||||
const flashMessageContainer = document.getElementById('flashalert_message_holder')
|
||||
ok(flashMessageContainer.childElementCount > 0)
|
||||
})
|
||||
|
||||
QUnit.module('SearchForm Autocomplete', {
|
||||
setup() {
|
||||
this.props = {
|
||||
...defaultProps(),
|
||||
fetchHistoryStatus: 'started',
|
||||
clearSearchOptions: sinon.stub(),
|
||||
getSearchOptions: sinon.stub(),
|
||||
getSearchOptionsNextPage: sinon.stub()
|
||||
}
|
||||
|
||||
this.wrapper = mount(<SearchFormComponent {...this.props} />)
|
||||
},
|
||||
|
||||
teardown() {
|
||||
this.wrapper.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
test('typing more than two letters for assignments hits getSearchOptions prop', function() {
|
||||
const input = this.wrapper.find('#assignments').last()
|
||||
input.simulate('change', {target: {id: 'assignments', value: 'Chapter 11 Questions'}})
|
||||
strictEqual(this.props.getSearchOptions.callCount, 1)
|
||||
})
|
||||
|
||||
test('typing more than two letters for graders hits getSearchOptions prop', function() {
|
||||
const input = this.wrapper.find('#graders').last()
|
||||
input.simulate('change', {target: {id: 'graders', value: 'Norval'}})
|
||||
strictEqual(this.props.getSearchOptions.callCount, 1)
|
||||
})
|
||||
|
||||
test('typing more than two letters for students hits getSearchOptions prop if not empty', function() {
|
||||
const input = this.wrapper.find('#students').last()
|
||||
input.simulate('change', {target: {id: 'students', value: 'Norval'}})
|
||||
strictEqual(this.props.getSearchOptions.callCount, 1)
|
||||
})
|
||||
|
||||
test('typing two or fewer letters for assignments hits clearSearchOptions prop if not empty', function() {
|
||||
this.wrapper.setProps({
|
||||
assignments: {
|
||||
fetchStatus: 'success',
|
||||
items: [{id: '1', name: 'Gary'}],
|
||||
nextPage: ''
|
||||
}
|
||||
})
|
||||
const input = this.wrapper.find('#assignments').last()
|
||||
input.simulate('change', {target: {id: 'assignments', value: 'ab'}})
|
||||
strictEqual(this.props.clearSearchOptions.callCount, 1)
|
||||
})
|
||||
|
||||
test('typing two or fewer letters for graders hits clearSearchOptions prop', function() {
|
||||
this.wrapper.setProps({
|
||||
graders: {
|
||||
fetchStatus: 'success',
|
||||
items: [{id: '1', name: 'Gary'}],
|
||||
nextPage: ''
|
||||
}
|
||||
})
|
||||
const input = this.wrapper.find('#graders').last()
|
||||
input.simulate('change', {target: {id: 'graders', value: 'ab'}})
|
||||
strictEqual(this.props.clearSearchOptions.callCount, 1)
|
||||
})
|
||||
|
||||
test('typing two or fewer letters for students hits clearSearchOptions prop if not empty', function() {
|
||||
this.wrapper.setProps({
|
||||
students: {
|
||||
fetchStatus: 'success',
|
||||
items: [{id: '1', name: 'Gary'}],
|
||||
nextPage: ''
|
||||
}
|
||||
})
|
||||
const input = this.wrapper.find('#students').last()
|
||||
input.simulate('change', {target: {id: 'students', value: 'ab'}})
|
||||
strictEqual(this.props.clearSearchOptions.callCount, 1)
|
||||
})
|
||||
|
||||
test('getSearchOptions is called with search term and input id', function() {
|
||||
const input = this.wrapper.find('#graders').last()
|
||||
const inputId = 'graders'
|
||||
const searchTerm = 'Norval Abbott'
|
||||
input.simulate('change', {target: {id: inputId, value: searchTerm}})
|
||||
strictEqual(this.props.getSearchOptions.firstCall.args[0], inputId)
|
||||
strictEqual(this.props.getSearchOptions.firstCall.args[1], searchTerm)
|
||||
})
|
||||
|
||||
test('getSearchOptionsNextPage is called if there are more options to load', function() {
|
||||
this.wrapper.setProps({
|
||||
students: {
|
||||
fetchStatus: 'success',
|
||||
items: [],
|
||||
nextPage: 'https://example.com'
|
||||
}
|
||||
})
|
||||
strictEqual(this.props.getSearchOptionsNextPage.firstCall.args[0], 'students')
|
||||
strictEqual(this.props.getSearchOptionsNextPage.firstCall.args[1], 'https://example.com')
|
||||
})
|
||||
|
||||
QUnit.module('SearchForm Autocomplete options', {
|
||||
setup() {
|
||||
this.props = {...defaultProps(), clearSearchOptions: sinon.stub()}
|
||||
this.props = {...defaultProps(), getSearchOptions: sinon.stub()}
|
||||
this.assignments = Fixtures.assignmentArray()
|
||||
this.graders = Fixtures.userArray()
|
||||
this.students = Fixtures.userArray()
|
||||
|
@ -400,7 +289,7 @@ test('selecting an assignment from options sets state to its id', function() {
|
|||
strictEqual(this.wrapper.state().selected.assignment, this.assignments[0].id)
|
||||
})
|
||||
|
||||
test('selecting an assignment from options clears options for assignments', function() {
|
||||
test('selecting an assignment from options sets that option in the list', function() {
|
||||
this.wrapper.setProps({
|
||||
assignments: {
|
||||
fetchStatus: 'success',
|
||||
|
@ -420,8 +309,9 @@ test('selecting an assignment from options clears options for assignments', func
|
|||
.find(span => assignmentNames.includes(span.textContent))
|
||||
.click()
|
||||
|
||||
ok(this.props.clearSearchOptions.called)
|
||||
strictEqual(this.props.clearSearchOptions.firstCall.args[0], 'assignments')
|
||||
ok(this.props.getSearchOptions.called)
|
||||
strictEqual(this.props.getSearchOptions.firstCall.args[0], 'assignments')
|
||||
strictEqual(this.props.getSearchOptions.firstCall.args[1], this.assignments[0].name)
|
||||
})
|
||||
|
||||
test('selecting an assignment from options sets showFinalGradeOverridesOnly to false', function() {
|
||||
|
@ -455,7 +345,7 @@ test('selecting an assignment from options sets showFinalGradeOverridesOnly to f
|
|||
strictEqual(this.wrapper.state().selected.showFinalGradeOverridesOnly, false)
|
||||
})
|
||||
|
||||
test('selecting a grader from options clears options for graders', function() {
|
||||
test('selecting a grader from options sets that option in the list', function() {
|
||||
this.wrapper.setProps({
|
||||
graders: {
|
||||
fetchStatus: 'success',
|
||||
|
@ -475,11 +365,12 @@ test('selecting a grader from options clears options for graders', function() {
|
|||
.find(span => graderNames.includes(span.textContent))
|
||||
.click()
|
||||
|
||||
ok(this.props.clearSearchOptions.called)
|
||||
strictEqual(this.props.clearSearchOptions.firstCall.args[0], 'graders')
|
||||
ok(this.props.getSearchOptions.called)
|
||||
strictEqual(this.props.getSearchOptions.firstCall.args[0], 'graders')
|
||||
strictEqual(this.props.getSearchOptions.firstCall.args[1], this.graders[0].name)
|
||||
})
|
||||
|
||||
test('selecting a student from options clears options for students', function() {
|
||||
test('selecting a student from options sets that option in the list', function() {
|
||||
this.wrapper.setProps({
|
||||
students: {
|
||||
fetchStatus: 'success',
|
||||
|
@ -499,74 +390,9 @@ test('selecting a student from options clears options for students', function()
|
|||
;[...document.getElementsByTagName('span')]
|
||||
.find(span => studentNames.includes(span.textContent))
|
||||
.click()
|
||||
ok(this.props.clearSearchOptions.called)
|
||||
strictEqual(this.props.clearSearchOptions.firstCall.args[0], 'students')
|
||||
})
|
||||
|
||||
test('no search records found for students results in a message instead', function() {
|
||||
this.wrapper.setProps({
|
||||
students: {
|
||||
fetchStatus: 'success',
|
||||
items: [],
|
||||
nextPage: ''
|
||||
}
|
||||
})
|
||||
|
||||
this.wrapper
|
||||
.find('#students')
|
||||
.last()
|
||||
.instance()
|
||||
.click()
|
||||
|
||||
const noRecords = [...document.getElementsByTagName('span')].find(
|
||||
span => span.textContent === 'No students with that name found'
|
||||
)
|
||||
|
||||
ok(noRecords)
|
||||
})
|
||||
|
||||
test('no search records found for graders results in a message instead', function() {
|
||||
this.wrapper.setProps({
|
||||
graders: {
|
||||
fetchStatus: 'success',
|
||||
items: [],
|
||||
nextPage: ''
|
||||
}
|
||||
})
|
||||
|
||||
this.wrapper
|
||||
.find('#graders')
|
||||
.last()
|
||||
.instance()
|
||||
.click()
|
||||
|
||||
const noRecords = [...document.getElementsByTagName('span')].find(
|
||||
span => span.textContent === 'No graders with that name found'
|
||||
)
|
||||
|
||||
ok(noRecords)
|
||||
})
|
||||
|
||||
test('no search records found for assignments results in a message instead', function() {
|
||||
this.wrapper.setProps({
|
||||
assignments: {
|
||||
fetchStatus: 'success',
|
||||
items: [],
|
||||
nextPage: ''
|
||||
}
|
||||
})
|
||||
|
||||
this.wrapper
|
||||
.find('#assignments')
|
||||
.last()
|
||||
.instance()
|
||||
.click()
|
||||
|
||||
const noRecords = [...document.getElementsByTagName('span')].find(
|
||||
span => span.textContent === 'No artifacts with that name found'
|
||||
)
|
||||
|
||||
ok(noRecords)
|
||||
ok(this.props.getSearchOptions.called)
|
||||
strictEqual(this.props.getSearchOptions.firstCall.args[0], 'students')
|
||||
strictEqual(this.props.getSearchOptions.firstCall.args[1], this.students[0].name)
|
||||
})
|
||||
|
||||
QUnit.module('SearchForm "Show Final Grade Overrides Only" checkbox', () => {
|
||||
|
|
Loading…
Reference in New Issue