Fix Select deprecations with CanvaSelect

this commit add the new CanvasSelect which wraps the new controlled-only
INSTUI Select (single select only) to provide a nearly drop-in
replacement for the deprecated INSTUI Select used in canvas.

This new CanvasSelect is then used in people_search.js and
TimeZoneSelect/index.js to resolve the
deprectation warnings.

changes include an upgrade to ui-select 6.8

closes ADMIN-2775, COREFE-186 COREFE-184

test plan:
  - nav to a course's people page
  - click on the +People button
  > expect the Role and Section selects to work as expected
  - nav to the account's people page
  - click on the pencil icon to the right of a user
  > expect the Time Zone select to show a blank line, then 2 groups
    of time zones
  > expect the select to work as expected
  > expect screenreaders to tell you interesting things as you
    interact with the select

Change-Id: I5dcfb2c1c8ca64071ce9dbf0a194777f10c711cf
Reviewed-on: https://gerrit.instructure.com/202508
Reviewed-by: Ryan Shaw <ryan@instructure.com>
QA-Review: Ryan Shaw <ryan@instructure.com>
Tested-by: Jenkins
Product-Review: Ed Schiebel <eschiebel@instructure.com>
This commit is contained in:
Ed Schiebel 2019-07-22 14:58:34 -06:00
parent 62ebbad0ae
commit 28ea3000ef
17 changed files with 861 additions and 124 deletions

View File

@ -314,6 +314,7 @@ export default class AddPeople extends React.Component {
shouldCloseOnDocumentClick={false}
size="medium"
tabIndex="-1"
liveRegion={getLiveRegion}
>
<ModalHeader>
<CloseButton
@ -362,3 +363,7 @@ export default class AddPeople extends React.Component {
)
}
}
function getLiveRegion() {
return document.getElementById('flash_screenreader_holder')
}

View File

@ -173,16 +173,19 @@ import Button from '@instructure/ui-buttons/lib/components/Button'
label={<ScreenReaderContent>{nameLabel}</ScreenReaderContent>}
data-address={missing.address}
onChange={this.onNewForMissingChange}
value={missing.newUserInfo.name || ''}
/>
</td>
<td>
<TextInput
required
name="email"
type="email"placeholder={emailLabel}
type="email"
placeholder={emailLabel}
label={<ScreenReaderContent>{emailLabel}</ScreenReaderContent>}
data-address={missing.address}
onChange={this.onNewForMissingChange}
value={missing.newUserInfo.email || ''}
/>
</td>
<th scope="row">{missing.address}</th>
@ -245,7 +248,7 @@ import Button from '@instructure/ui-buttons/lib/components/Button'
label={<ScreenReaderContent>{nameLabel}</ScreenReaderContent>}
data-address={missing.address}
onChange={this.onNewForMissingChange}
value={missing.newUserInfo.name || null}
value={missing.newUserInfo.name || ''}
/>
</td>
<th scope="row">{missing.address}</th>

View File

@ -21,7 +21,7 @@ import React from 'react'
import Text from '@instructure/ui-elements/lib/components/Text'
import RadioInputGroup from '@instructure/ui-forms/lib/components/RadioInputGroup'
import RadioInput from '@instructure/ui-forms/lib/components/RadioInput'
import Select from '@instructure/ui-core/lib/components/Select'
import CanvasSelect from '../../shared/components/CanvasSelect'
import TextArea from '@instructure/ui-forms/lib/components/TextArea'
import ScreenReaderContent from '@instructure/ui-a11y/lib/components/ScreenReaderContent'
import Checkbox from '@instructure/ui-forms/lib/components/Checkbox'
@ -63,11 +63,11 @@ import {parseNameList, findEmailInEntry, emailValidator} from '../helpers'
this.props.onChange({nameList: event.target.value});
}
onChangeSection = (event) => {
this.props.onChange({section: event.target.value});
onChangeSection = (event, optionValue) => {
this.props.onChange({section: optionValue});
}
onChangeRole = (event) => {
this.props.onChange({role: event.target.value});
onChangeRole = (event, optionValue) => {
this.props.onChange({role: optionValue});
}
onChangePrivilege = (event) => {
this.props.onChange({limitPrivilege: event.target.checked});
@ -155,28 +155,28 @@ import {parseNameList, findEmailInEntry, emailValidator} from '../helpers'
<fieldset className="peoplesearch__selections">
<div>
<div className="peoplesearch__selection">
<Select
id="peoplesearch_select_role"
<CanvasSelect
label={I18n.t('Role')}
value={this.props.role}
id="peoplesearch_select_role"
value={this.props.role || (this.props.roles.length ? this.props.roles[0].id : null)}
onChange={this.onChangeRole}
>
{
this.props.roles.map(r => <option key={`r_${r.name}`} value={r.id}>{r.label}</option>)
}
</Select>
{this.props.roles.map(r => (
<CanvasSelect.Option key={r.id} id={r.id} value={r.id}>{r.label}</CanvasSelect.Option>
))}
</CanvasSelect>
</div>
<div className="peoplesearch__selection">
<Select
id="peoplesearch_select_section"
<CanvasSelect
label={I18n.t('Section')}
value={this.props.section}
id="peoplesearch_select_section"
value={this.props.section || (this.props.sections.length ? this.props.sections[0].id : null)}
onChange={this.onChangeSection}
>
{
this.props.sections.map(s => <option key={`s_${s.id}`} value={s.id}>{s.name}</option>)
}
</Select>
{this.props.sections.map(s => (
<CanvasSelect.Option key={s.id} id={s.id} value={s.id}>{s.name}</CanvasSelect.Option>
))}
</CanvasSelect>
</div>
</div>
<div style={{marginTop: '1em'}}>

View File

@ -0,0 +1,299 @@
/*
* 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/>.
*/
/*
---
CanvasSelect is a wrapper on the new (as of instui 5 or 6 or so) controlled-only Select
While CanvasSelect is also controlled-only, it has a simpler api and is almost a drop-in
replacement for the old instui Select used throughout canvas at this time. One big difference
is the need to pass in an options property rather than rendering <Options> children
It does not currently support old-Select's allowCustom property
(see https://instructure.design/#DeprecatedSelect)
It only handles single-select. Multi-select will likely have to be in a separate component
<CanvasSelect
id="your-id"
label="select's label"
value={value} // should match the ID of the selected option
onChange={handleChange} // function(event, selectedOption)
{...otherPropsPassedToTheUnderlyingSelect} // if you need to (width="100%" is a popular one)
>
<CanvasSelect.Option key="1" id="1" value="1">one</CanvasSelect.Option>
<CanvasSelect.Option key="2" id="2" value="2">two</CanvasSelect.Option>
<CanvasSelect.Option key="3" id="3" value="3">three</CanvasSelect.Option>
</CanvasSelect>
---
*/
import React from 'react'
import {func, node, string} from 'prop-types'
import I18n from 'i18n!app_shared_components'
import {Select} from '@instructure/ui-select'
import {Alert} from '@instructure/ui-alerts'
import {matchComponentTypes} from '@instructure/ui-react-utils'
const noOptionsOptionId = '_noOptionsOption'
// CanvasSelectOption and CanvasSelectGroup are components our client can create thru CanvasSelect
// to pass us our options. They are never rendered themselves, but get transformed into INSTUI's
// Select.Option and Select.Group on rendering CanvasSelect. See renderChildren below.
function CanvasSelectOption() {
return <div />
}
CanvasSelectOption.propTypes = {
id: string.isRequired, // eslint-disable-line react/no-unused-prop-types
value: string.isRequired // eslint-disable-line react/no-unused-prop-types
}
function CanvasSelectGroup() {
return <div />
}
CanvasSelectGroup.propTypes = {
label: string.isRequired // eslint-disable-line react/no-unused-prop-types
}
export default class CanvasSelect extends React.Component {
static Option = CanvasSelectOption
static Group = CanvasSelectGroup
static propTypes = {
id: string,
label: string.isRequired,
value: string.isRequired,
onChange: func.isRequired,
children: node,
noOptionsLabel: string // unselectable option to display when there are no options
}
static defaultProps = {
noOptionsLabel: '---'
}
constructor(props) {
super(props)
const option = this.getOptionByFieldValue('value', props.value)
this.state = {
inputValue: option ? option.props.children : '',
isShowingOptions: false,
highlightedOptionId: null,
selectedOptionId: option ? option.props.id : null,
announcement: null
}
}
render() {
const {id, label, value, onChange, children, noOptionsLabel, ...otherProps} = this.props
return (
<React.Fragment>
<Select
id={id}
renderLabel={() => label}
assistiveText={I18n.t('Use arrow keys to navigate options.')}
inputValue={this.state.inputValue}
isShowingOptions={this.state.isShowingOptions}
onBlur={this.handleBlur}
onRequestShowOptions={this.handleShowOptions}
onRequestHideOptions={this.handleHideOptions}
onRequestHighlightOption={this.handleHighlightOption}
onRequestSelectOption={this.handleSelectOption}
{...otherProps}
>
{this.renderChildren(children)}
</Select>
<Alert
liveRegion={() => document.getElementById('flash_screenreader_holder')}
liveRegionPoliteness="assertive"
screenReaderOnly
>
{this.state.announcement}
</Alert>
</React.Fragment>
)
}
renderChildren(children) {
if (!Array.isArray(children)) {
// children is 1 child
if (matchComponentTypes(children, [CanvasSelectOption])) {
return this.renderOption(children)
} else {
return this.renderNoOptionsOption()
}
}
const opts = children
.map(child => {
if (Array.isArray(child)) {
return this.renderChildren(child)
} else if (matchComponentTypes(child, [CanvasSelectOption])) {
return this.renderOption(child)
} else if (matchComponentTypes(child, [CanvasSelectGroup])) {
return this.renderGroup(child)
}
return null
})
.filter(child => !!child) // instui Select blows up on undefined options
if (opts.length === 0) {
return this.renderNoOptionsOption()
}
return opts
}
backupKey = 0
renderOption(option) {
const {id, children, ...optionProps} = option.props
return (
<Select.Option
id={id}
key={option.key || id || ++this.backupKey}
isHighlighted={id === this.state.highlightedOptionId}
isSelected={id === this.state.selectedOptionId}
{...optionProps}
>
{children}
</Select.Option>
)
}
renderGroup(group) {
const {id, label, ...otherProps} = group.props
return (
<Select.Group
data-testid={`Group:${label}`}
renderLabel={() => label}
key={group.key || id || ++this.backupKey}
{...otherProps}
>
{group.props.children.map(c => this.renderOption(c))}
</Select.Group>
)
}
renderNoOptionsOption() {
return (
<Select.Option id={noOptionsOptionId} isHighlighted={false} isSelected={false}>
{this.props.noOptionsLabel}
</Select.Option>
)
}
handleBlur = _event => {
this.setState({highlightedOptionId: null})
}
handleShowOptions = () => {
this.setState({
isShowingOptions: true
})
}
handleHideOptions = _event => {
this.setState(state => {
const text = this.getOptionLabelById(state.selectedOptionId)
return {
isShowingOptions: false,
highlightedOptionId: null,
inputValue: text,
announcement: I18n.t('List collapsed.')
}
})
}
/* eslint-disable react/no-access-state-in-setstate */
// Beause handleShowOptions sets state.isShowingOptions:true
// it's already in the value of state passed to the setState(updater)
// by the time handleHighlightOption is called we miss the transition,
// this.state still has the previous value as of the last render
// which is what we need. This is why we use this version of setState.
handleHighlightOption = (event, {id}) => {
if (id === noOptionsOptionId) return
const text = this.getOptionLabelById(id)
const nowOpen = this.state.isShowingOptions ? '' : I18n.t('List expanded.')
const inputValue = event.type === 'keydown' ? text : this.state.inputValue
this.setState({
highlightedOptionId: id,
inputValue,
announcement: `${text} ${nowOpen}`
})
}
/* eslint-enable react/no-access-state-in-setstate */
handleSelectOption = (event, {id}) => {
if (id === noOptionsOptionId) {
this.setState({
isShowingOptions: false,
announcement: I18n.t('List collapsed')
})
} else {
const text = this.getOptionLabelById(id)
const prevSelection = this.state.selectedOptionId
this.setState({
selectedOptionId: id,
inputValue: text,
isShowingOptions: false,
announcement: I18n.t('%{option} selected. List collapsed.', {option: text})
})
const option = this.getOptionByFieldValue('id', id)
if (prevSelection !== id) {
this.props.onChange(event, option.props.value)
}
}
}
getOptionLabelById(oid) {
const option = this.getOptionByFieldValue('id', oid)
return option ? option.props.children : ''
}
getOptionByFieldValue(field, value, options = this.props.children) {
if (!this.props.children) return null
let foundOpt = null
for (let i = 0; i < options.length; ++i) {
const o = options[i]
if (Array.isArray(o)) {
foundOpt = this.getOptionByFieldValue(field, value, o)
} else if (matchComponentTypes(o, [CanvasSelectOption])) {
if (o.props[field] === value) {
foundOpt = o
}
} else if (matchComponentTypes(o, [CanvasSelectGroup])) {
for (let j = 0; j < o.props.children.length; ++j) {
const o2 = o.props.children[j]
if (o2.props[field] === value) {
foundOpt = o2
break
}
}
}
if (foundOpt) {
break
}
}
return foundOpt
}
}

View File

@ -213,7 +213,7 @@ export default class CreateOrUpdateUserModal extends React.Component {
onSubmit={preventDefault(this.onSubmit)}
open={this.state.open}
onDismiss={this.close}
size="small"
size="medium"
label={
this.props.createOrUpdate === 'create' ? (
I18n.t('Add a New User')

View File

@ -0,0 +1,136 @@
/*
* 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 TimeZoneSelect from '../index'
import {render} from '@testing-library/react'
import isEqual from 'lodash/isEqual'
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()
}
})
const timezones = [
{
name: 'Central',
localized_name: 'Central localized'
},
{
name: 'Eastern',
localized_name: 'Eastern localized'
},
{
name: 'Mountain',
localized_name: 'Mountain localized'
},
{
name: 'Pacific',
localized_name: 'Pacific localized'
}
]
const priorityZones = [timezones[0]]
describe('TimeZoneSelect', () => {
it('renders the value', () => {
const {getByDisplayValue} = render(
<TimeZoneSelect
label="the label"
onChange={() => {}}
value="Mountain"
timezones={timezones}
priority_zones={priorityZones}
/>
)
expect(getByDisplayValue('Mountain localized')).toBeInTheDocument()
})
it('renders the right zone options', () => {
const {getByText} = render(
<TimeZoneSelect
label="the label"
onChange={() => {}}
value="Mountain"
timezones={timezones}
priority_zones={priorityZones}
/>
)
// open the select dropdown
const label = getByText('the label')
label.click()
const priorityOptions = document.querySelectorAll(
'[data-testid="Group:Common Timezones"] span[role="option"]'
)
isEqual(
Array.from(priorityOptions).map(e => ({
name: e.getAttribute('value'),
localized_name: e.textContent
})),
priorityZones
)
const allOptions = document.querySelectorAll(
'[data-testid="Group:All Timezones"] span[role="option"]'
)
isEqual(
Array.from(allOptions).map(e => ({
name: e.getAttribute('value'),
localized_name: e.textContent
})),
timezones
)
})
it('calls onChange on a selection', () => {
const onChangeTZ = jest.fn()
const {getByText} = render(
<TimeZoneSelect
label="the label"
onChange={onChangeTZ}
timezones={timezones}
priority_zones={priorityZones}
/>
)
// open the select dropdown
const label = getByText('the label')
label.click()
const eastern = getByText('Eastern localized')
eastern.click()
// onChange's event.target.value === onChanges's 2nd argument
expect(onChangeTZ).toHaveBeenCalled()
expect(onChangeTZ.mock.calls[0][0].target.value).toEqual('Eastern')
expect(onChangeTZ.mock.calls[0][1]).toEqual('Eastern')
})
})

View File

@ -18,31 +18,41 @@
import React from 'react'
import {arrayOf, shape, string} from 'prop-types'
import Select from '@instructure/ui-core/lib/components/Select'
import CanvasSelect from '../CanvasSelect'
import I18n from 'i18n!edit_timezone'
export default function TimeZoneSelect({
label,
timezones,
priority_zones,
onChange,
...otherPropsToPassOnToSelect
}) {
let idval = 0 // for setting ids on options, which are necessary for Select's inner workings but don't matter to us
function onChangeTimezone(event, value) {
event.persist()
event.target.value = value // this is how our onChange expects the result
onChange(event, value) // so it works either way, instui Select callback, or traditional
}
return (
<Select {...otherPropsToPassOnToSelect} label={label}>
<option value="" />
<CanvasSelect label={label} onChange={onChangeTimezone} {...otherPropsToPassOnToSelect}>
<CanvasSelect.Option id={`${++idval}`} value="">
&nbsp;
</CanvasSelect.Option>
{[
{label: I18n.t('Common Timezones'), timezones: priority_zones},
{label: I18n.t('All Timezones'), timezones}
].map(({label, timezones}) => (
<optgroup key={label} label={label}>
{timezones.map(zone => (
<option key={zone.name} value={zone.name}>
].map(grouping => (
<CanvasSelect.Group key={grouping.label} label={grouping.label}>
{grouping.timezones.map(zone => (
<CanvasSelect.Option id={`${++idval}`} key={zone.name} value={zone.name}>
{zone.localized_name}
</option>
</CanvasSelect.Option>
))}
</optgroup>
</CanvasSelect.Group>
))}
</Select>
</CanvasSelect>
)
}
@ -52,14 +62,14 @@ const timezoneShape = shape({
}).isRequired
TimeZoneSelect.propTypes = {
...Select.propTypes, // this accepts any prop you'd pass to InstUI's Select. see it's docs for examples
...CanvasSelect.propTypes, // this accepts any prop you'd pass to InstUI's Select. see it's docs for examples
timezones: arrayOf(timezoneShape),
priority_zones: arrayOf(timezoneShape)
}
let defaultsJSON
try {
defaultsJSON = require(`./localized-timezone-lists/${ENV.LOCALE || 'en'}.json`)
defaultsJSON = require(`./localized-timezone-lists/${ENV.LOCALE || 'en'}.json`) // eslint-disable-line import/no-dynamic-require
} catch (e) {
// fall back to english if a user has a locale set that we don't have a list for
defaultsJSON = require(`./localized-timezone-lists/en.json`)

View File

@ -0,0 +1,244 @@
/*
* 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 '@instructure/ui-themes/lib/canvas'
import React from 'react'
import CanvasSelect from '../CanvasSelect'
import {render} from '@testing-library/react'
function selectProps(override = {}) {
return {
id: 'sel1',
label: 'Choose one',
value: {undefined},
onChange: () => {},
...override
}
}
function selectOpts() {
return [
<CanvasSelect.Option key="1" id="1" value="one">
One
</CanvasSelect.Option>,
<CanvasSelect.Option key="2" id="2" value="two">
Two
</CanvasSelect.Option>,
<CanvasSelect.Option key="3" id="3" value="three">
Three
</CanvasSelect.Option>
]
}
function renderSelect(otherProps) {
return render(<CanvasSelect {...selectProps(otherProps)}>{selectOpts()}</CanvasSelect>)
}
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('CanvasSelect component', () => {
it('renders', () => {
const {getByText} = renderSelect()
expect(getByText('Choose one')).toBeInTheDocument()
})
it('shows the selected option', () => {
const {getByDisplayValue} = renderSelect({value: 'two'})
expect(getByDisplayValue('Two')).toBeInTheDocument()
})
it('calls onChange when selection changes', () => {
const handleChange = jest.fn()
const {getByText} = renderSelect({onChange: handleChange})
const label = getByText('Choose one')
label.click()
// the options list is open now
const three = getByText('Three')
three.click()
expect(handleChange).toHaveBeenCalled()
})
it('forwards the isDisabled prop', () => {
const handleChange = jest.fn()
const {getByText} = render(
<CanvasSelect {...selectProps({onChange: handleChange})}>
<CanvasSelect.Option key="1" id="1" value="one">
One
</CanvasSelect.Option>
<CanvasSelect.Option key="2" id="2" value="two">
Two
</CanvasSelect.Option>
<CanvasSelect.Option key="3" id="3" value="three" isDisabled>
Three
</CanvasSelect.Option>
</CanvasSelect>
)
const label = getByText('Choose one')
label.click()
// the options list is open now
const three = getByText('Three')
three.click()
expect(handleChange).not.toHaveBeenCalled()
})
it('filters out undefined options', () => {
const {getByText} = render(
<CanvasSelect {...selectProps()}>
<CanvasSelect.Option key="1" id="1" value="one">
One
</CanvasSelect.Option>
undefined
<CanvasSelect.Option key="3" id="3" value="three" isDisabled>
Three
</CanvasSelect.Option>
</CanvasSelect>
)
const label = getByText('Choose one')
label.click()
expect(getByText('One')).toBeInTheDocument()
expect(getByText('Three')).toBeInTheDocument()
})
it('handles no children', () => {
const {getByText} = render(
<CanvasSelect {...selectProps({noOptionsLabel: 'No Options'})}></CanvasSelect>
)
const label = getByText('Choose one')
label.click()
expect(getByText('No Options')).toBeInTheDocument()
})
it('handles no options', () => {
const {getByText} = render(
<CanvasSelect {...selectProps({noOptionsLabel: 'No Options'})}>what is this?</CanvasSelect>
)
const label = getByText('Choose one')
label.click()
expect(getByText('No Options')).toBeInTheDocument()
})
describe('CanvasSelectGroups', () => {
it('renders enumerated groups and options', () => {
const {getByText} = render(
<CanvasSelect {...selectProps()}>
<CanvasSelect.Group id="1" label="Group A">
<CanvasSelect.Option id="1" value="one">
One
</CanvasSelect.Option>
<CanvasSelect.Option id="2" value="two">
Two
</CanvasSelect.Option>
<CanvasSelect.Option id="3" value="three">
Three
</CanvasSelect.Option>
</CanvasSelect.Group>
<CanvasSelect.Group id="2" label="Group B">
<CanvasSelect.Option id="4" value="four">
Four
</CanvasSelect.Option>
<CanvasSelect.Option id="5" value="five">
Five
</CanvasSelect.Option>
<CanvasSelect.Option id="6" value="siz">
Six
</CanvasSelect.Option>
</CanvasSelect.Group>
</CanvasSelect>
)
expect(getByText('Choose one')).toBeInTheDocument()
const label = getByText('Choose one')
label.click()
expect(getByText('Group A')).toBeInTheDocument()
expect(getByText('One')).toBeInTheDocument()
expect(getByText('Group B')).toBeInTheDocument()
expect(getByText('Four')).toBeInTheDocument()
})
})
it('renders generated groups and options', () => {
const data = [
{
label: 'Group A',
items: [
{id: '1', value: 'one', label: 'One'},
{id: '2', value: 'two', label: 'Two'},
{id: '3', value: 'three', label: 'Three'}
]
},
{
label: 'Group B',
items: [
{id: '4', value: 'four', label: 'Four'},
{id: '5', value: 'five', label: 'Five'},
{id: '6', value: 'siz', label: 'Six'}
]
}
]
let k = 0
const {getByText} = render(
<CanvasSelect {...selectProps()}>
<CanvasSelect.Option id="0" value="0">
Zero
</CanvasSelect.Option>
{data.map(grp => (
<CanvasSelect.Group key={`${++k}`} label={grp.label}>
{grp.items.map(opt => (
<CanvasSelect.Option key={`${++k}`} id={opt.id} value={opt.value}>
{opt.label}
</CanvasSelect.Option>
))}
</CanvasSelect.Group>
))}
</CanvasSelect>
)
expect(getByText('Choose one')).toBeInTheDocument()
const label = getByText('Choose one')
label.click()
expect(getByText('Group A')).toBeInTheDocument()
expect(getByText('One')).toBeInTheDocument()
expect(getByText('Group B')).toBeInTheDocument()
expect(getByText('Four')).toBeInTheDocument()
})
})

View File

@ -40,6 +40,7 @@
"@instructure/ui-number-input": "^5",
"@instructure/ui-overlays": "^5",
"@instructure/ui-pagination": "^5",
"@instructure/ui-select": "^6.8.1",
"@instructure/ui-svg-images": "^5",
"@instructure/ui-table": "^5",
"@instructure/ui-tabs": "^5",
@ -122,6 +123,7 @@
"@sentry/webpack-plugin": "^1.5.2",
"@sheerun/mutationobserver-shim": "0.3.2",
"@testing-library/dom": "^5",
"@testing-library/jest-dom": "^4",
"@testing-library/react": "^8.0.5",
"@yarnpkg/lockfile": "^1.0.2",
"axe-core": "~2.1.7",
@ -179,7 +181,6 @@
"jest": "^24",
"jest-canvas-mock": "^1",
"jest-config": "^24",
"@testing-library/jest-dom": "^4",
"jest-fetch-mock": "^2.1.2",
"jest-junit": "^6",
"jest-localstorage-mock": "^2",

View File

@ -23,8 +23,8 @@ import PeopleSearch from 'jsx/add_people/components/people_search'
QUnit.module('PeopleSearch')
const searchProps = {
roles: [{id: '1', name: 'Student'}, {id: '2', name: 'TA'}],
sections: [{id: '1', name: 'section 2'}, {id: '2', name: 'section 10'}],
roles: [{id: '1', label: 'Student'}, {id: '2', label: 'TA'}],
sections: [{id: '1', name: 'Section 2'}, {id: '2', name: 'Section 10'}],
section: '1',
role: '2',
limitPrivilege: true,
@ -51,11 +51,10 @@ test('sets the correct values', () => {
equal(loginRadio.checked, true, 'login id radio button is checked')
const nameInput = peopleSearch.querySelector('textarea')
equal(nameInput.value, searchProps.nameList, 'names are in the textarea')
const selects = peopleSearch.querySelectorAll('.peoplesearch__selections select')
equal(selects[0].value, '2', 'role 2 is selected')
equal(selects[1].value, '1', 'section 1 is selected')
const sections = Array.prototype.map.call(selects[1].options, o => o.innerHTML)
deepEqual(sections, ['section 2', 'section 10'], 'sections are sorted by name')
const roleSelect = peopleSearch.querySelector('#peoplesearch_select_role')
equal(roleSelect.value, 'TA', 'correct role is selected')
const sectionSelect = peopleSearch.querySelector('#peoplesearch_select_section')
equal(sectionSelect.value, 'Section 2', 'correct section is selected')
const limitPrivilegeCheckbox = peopleSearch.querySelector('#limit_privileges_to_course_section')
equal(limitPrivilegeCheckbox.checked, true, 'limit privileges checkbox is checked')
})

View File

@ -1,59 +0,0 @@
/*
* Copyright (C) 2015 - 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 {shallow} from 'enzyme'
import TimeZoneSelect from 'jsx/shared/components/TimeZoneSelect'
QUnit.module('TimeZoneSelect')
test('renders the right zones', () => {
const timezones = [
{
name: 'Central',
localized_name: 'Central localized'
},
{
name: 'Eastern',
localized_name: 'Eastern localized'
},
{
name: 'Mountain',
localized_name: 'Mountain localized'
},
{
name: 'Pacific',
localized_name: 'Pacific localized'
}
]
const priorityZones = [timezones[0]]
const wrapper = shallow(<TimeZoneSelect timezones={timezones} priority_zones={priorityZones} />)
const prorityOptions = wrapper.find('optgroup[label="Common Timezones"] option')
deepEqual(
prorityOptions.map(e => ({name: e.prop('value'), localized_name: e.text()})),
priorityZones
)
const allOptions = wrapper.find('optgroup[label="All Timezones"] option')
deepEqual(
allOptions.map(e => ({name: e.prop('value'), localized_name: e.text()})),
timezones
)
})

View File

@ -122,7 +122,7 @@ describe "add_people" do
:enabled => false
get "/courses/#{@course.id}/users"
f('#addUsers').click
expect(ff('#peoplesearch_select_role option').map(&:text)).not_to include 'Student'
expect(INSTUI_Select_options('#peoplesearch_select_role').map(&:text)).not_to include 'Student'
end
# CNVS-34781
@ -152,6 +152,28 @@ describe "add_people" do
end
# tests that INSTUI fixed a bug in Select that would close the Modal
# when the user uses 'esc' to close the options dropdown
it "should not close the modal on 'escape'ing from role Select options", ignore_js_errors: true do
get "/courses/#{@course.id}/users"
# open the add people modal dialog
f('a#addUsers').click
expect(f(".addpeople")).to be_displayed
# expand the roles
roleselect = f('#peoplesearch_select_role')
roleselect.click
option_list_id = roleselect.attribute('aria-controls')
expect(f('body')).to contain_css("##{option_list_id}")
optionList = f("##{option_list_id}")
roleselect.send_keys(:escape)
expect(f('body')).not_to contain_css("##{option_list_id}")
expect(f(".addpeople")).to be_displayed # still
end
end
context('as an admin') do

View File

@ -29,10 +29,10 @@ module NewCourseAddPeopleModal
end
def role_options
ff('#peoplesearch_select_role option', add_people_modal).map(&:text)
INSTUI_Select_options('#peoplesearch_select_role').map(&:text)
end
def section_options
ff('#peoplesearch_select_section option', add_people_modal).map(&:text)
INSTUI_Select_options('#peoplesearch_select_section').map(&:text)
end
end

View File

@ -37,8 +37,8 @@ describe "course people" do
add_button.click
wait_for_ajaximations
click_option("#peoplesearch_select_role", type)
click_option("#peoplesearch_select_section", section_name) if section_name
click_INSTUI_Select_option("#peoplesearch_select_role", type)
click_INSTUI_Select_option("#peoplesearch_select_section", section_name) if section_name
replace_content(f(".addpeople__peoplesearch textarea"), email)
f("#addpeople_next").click
@ -356,7 +356,7 @@ describe "course people" do
add_button = f('#addUsers')
keep_trying_until { expect(add_button).to be_displayed }
add_button.click
click_option('#role_id', type)
click_INSTUI_Select_option('#role_id', type)
end
%w[student teacher ta designer observer].each do |base_type|

View File

@ -425,8 +425,8 @@ describe "people" do
expect(f(".addpeople")).to be_displayed
replace_content(f(".addpeople__peoplesearch textarea"),'student@example.com')
click_option('#peoplesearch_select_role', ta_role.id.to_s, :value)
click_option('#peoplesearch_select_section', 'Unnamed Course', :text)
click_INSTUI_Select_option('#peoplesearch_select_role', ta_role.id.to_s, :value)
click_INSTUI_Select_option('#peoplesearch_select_section', 'Unnamed Course', :text)
f('#addpeople_next').click
wait_for_ajaximations
@ -437,8 +437,8 @@ describe "people" do
# verify form and options have not changed
expect(f('.addpeople__peoplesearch')).to be_displayed
expect(f('.addpeople__peoplesearch textarea').text).to eq 'student@example.com'
expect(first_selected_option(f('#peoplesearch_select_role')).text).to eq 'TA'
expect(first_selected_option(f('#peoplesearch_select_section')).text).to eq 'Unnamed Course'
expect(f('#peoplesearch_select_role').attribute('value')).to eq 'TA'
expect(f('#peoplesearch_select_section').attribute('value')).to eq 'Unnamed Course'
end
it "should add a student to a section", priority: "1", test_id: 296460 do

View File

@ -415,6 +415,26 @@ module CustomSeleniumActions
select.select_by(select_by, option_text)
end
# implementation of click_option for use with INSTU's Select
# (tested with the CanvasSelect wrapper, untested with a raw instui Select)
def click_INSTUI_Select_option(select_css, option_text, select_by = :text)
cselect = fj(select_css)
cselect.click # open the options list
option_list_id = cselect.attribute('aria-controls')
if select_by == :text
fj("##{option_list_id} [role='option']:contains(#{option_text})").click
else
f("##{option_list_id} [role='option'][#{select_by}='#{option_text}']").click
end
end
def INSTUI_Select_options(select_css)
cselect = fj(select_css)
cselect.click # open the options list
option_list_id = cselect.attribute('aria-controls')
ff("##{option_list_id} [role='option']")
end
def close_visible_dialog
visible_dialog_element = fj('.ui-dialog:visible')
visible_dialog_element.find_element(:css, '.ui-dialog-titlebar-close').click

View File

@ -2004,6 +2004,22 @@
keycode "^2"
prop-types "^15"
"@instructure/ui-options@^6.8.1":
version "6.8.1"
resolved "https://registry.yarnpkg.com/@instructure/ui-options/-/ui-options-6.8.1.tgz#c212f0c4819c146b095fe9b9f1e350399c45606b"
integrity sha512-KrT7S8wIrZXeyjHfMJu/dzR8EzKqirHCvT550Cltse57/JR1tZcM8fm0ZUwV+buUWE4HwmbYKZR/6OzVR3gong==
dependencies:
"@babel/runtime" "^7"
"@instructure/ui-icons" "^6.8.1"
"@instructure/ui-layout" "^6.8.1"
"@instructure/ui-prop-types" "^6.8.1"
"@instructure/ui-react-utils" "^6.8.1"
"@instructure/ui-testable" "^6.8.1"
"@instructure/ui-themeable" "^6.8.1"
classnames "^2"
prop-types "^15"
react "^15 || ^16"
"@instructure/ui-overlays@^5", "@instructure/ui-overlays@^5.52.0", "@instructure/ui-overlays@^5.52.3":
version "5.52.3"
resolved "https://registry.yarnpkg.com/@instructure/ui-overlays/-/ui-overlays-5.52.3.tgz#1a1c863abc2de2b787cd9f0101b2c956d4694b80"
@ -2190,6 +2206,47 @@
"@instructure/ui-utils" "^6.8.1"
prop-types "^15"
"@instructure/ui-select@^6.8.1":
version "6.8.1"
resolved "https://registry.yarnpkg.com/@instructure/ui-select/-/ui-select-6.8.1.tgz#0ad5086afdbadd9dde6723774c447400e5a90b74"
integrity sha512-b0af6oatUGrgFLUoL8T02Xdq/eLr2p3/S1lVHr0Vr2IR54ZBEi+lJ0sleAtksdhEGqb1Hkvu1ozoLDSsImUj5Q==
dependencies:
"@babel/runtime" "^7"
"@instructure/ui-dom-utils" "^6.8.1"
"@instructure/ui-elements" "^6.8.1"
"@instructure/ui-form-field" "^6.8.1"
"@instructure/ui-icons" "^6.8.1"
"@instructure/ui-layout" "^6.8.1"
"@instructure/ui-options" "^6.8.1"
"@instructure/ui-overlays" "^6.8.1"
"@instructure/ui-prop-types" "^6.8.1"
"@instructure/ui-react-utils" "^6.8.1"
"@instructure/ui-selectable" "^6.8.1"
"@instructure/ui-testable" "^6.8.1"
"@instructure/ui-text-input" "^6.8.1"
"@instructure/ui-themeable" "^6.8.1"
"@instructure/ui-utils" "^6.8.1"
"@instructure/uid" "^6.8.1"
classnames "^2"
prop-types "^15"
react "^15 || ^16"
"@instructure/ui-selectable@^6.8.1":
version "6.8.1"
resolved "https://registry.yarnpkg.com/@instructure/ui-selectable/-/ui-selectable-6.8.1.tgz#ba2813cfac1efe39d6f8fc7f2ded250d01fd39e8"
integrity sha512-WtlQihh/UiUt+4GjkHKEYN+muAMG8gFexb1kzellPb4C3dHl0fSfjxO4JlRQW4GWqUyDJeCjYjwemmYpPn2+ZQ==
dependencies:
"@babel/runtime" "^7"
"@instructure/console" "^6.8.1"
"@instructure/ui-dom-utils" "^6.8.1"
"@instructure/ui-testable" "^6.8.1"
"@instructure/ui-themeable" "^6.8.1"
"@instructure/ui-utils" "^6.8.1"
"@instructure/uid" "^6.8.1"
keycode "^2"
prop-types "^15"
react "^15 || ^16"
"@instructure/ui-stylesheet@^5.52.3":
version "5.52.3"
resolved "https://registry.yarnpkg.com/@instructure/ui-stylesheet/-/ui-stylesheet-5.52.3.tgz#4f35efb7ed6ceb3442a5efbf9ce80d9916e46322"
@ -11676,7 +11733,7 @@ iterall@^1.1.3, iterall@^1.2.2:
resolved "https://registry.yarnpkg.com/iterall/-/iterall-1.2.2.tgz#92d70deb8028e0c39ff3164fdbf4d8b088130cd7"
integrity sha512-yynBb1g+RFUPY64fTrFv7nsjRrENBQJaX2UL+2Szc9REFrSNm1rpSXHGzhmAy7a9uv3vlvgBlXnf9RqmPH1/DA==
jasmine-core@^2.2.0:
jasmine-core@2.6.4, jasmine-core@^2.2.0:
version "2.6.4"
resolved "https://registry.yarnpkg.com/jasmine-core/-/jasmine-core-2.6.4.tgz#dec926cd0a9fa287fb6db5c755fa487e74cecac5"
integrity sha1-3skmzQqfoof7bbXHVfpIfnTOysU=
@ -14290,7 +14347,7 @@ node-releases@^1.1.25:
dependencies:
semver "^5.3.0"
node-sass@^4.5.0, node-sass@^4.7.2:
node-sass@4.7.2, node-sass@^4.5.0, node-sass@^4.7.2:
version "4.7.2"
resolved "https://registry.yarnpkg.com/node-sass/-/node-sass-4.7.2.tgz#9366778ba1469eb01438a9e8592f4262bcb6794e"
integrity sha512-CaV+wLqZ7//Jdom5aUFCpGNoECd7BbNhjuwdsX/LkXBrHl8eb1Wjw4HvWqcFvhr5KuNgAk8i/myf/MQ1YYeroA==