use new addPeople dialog to fix a bunch of bugs

This changes things out so when you click the “+” button to add users
on a course row on /accounts/x, it uses the new React/Redux/InstUI based
“Add Users” modal that the people page in courses/x/users already uses
-- instead of the old Backbone-based one that they both used to use.

closes: CORE-319 CORE-603

Fixes: CORE-601
“when you open the "add people" to course modal, the "section" dropdown
is not populated”
test plan:
* click the “+” icon on a course row.
* the "section" dropdown should be populated with all the
  sections for that course

Fixes: CORE-602
“an n+1 query for sections on the courses page is making it so it takes
minutes for people to load the page in beta”
Test plan:
* open /accounts/x
* there should not be like 30 Ajax requests to api/v1/courses/x/sections
  for every course displayed in the search results
* click the “+” to add users to that course
* you should see an Ajax request to load that course’s sections and they
  Should be visible in the modal

Closes: CORE-604
Say "Add people to: Biology 101" instead of just "Add People" in modal
title.
Before it just said “Add people”. In the canvas community forums people
said it would be nice if it said what course they were adding people to
Test plan:
* On the page that lists the courses in an account, click the “+” on
  one of the course rows.
* the modal should say “Add people to: Biology 101” (where biology 101
  Is the name of the course row you clicked)

Change-Id: If2c7bca18bab643d4c0c4379ff8af21fff382aca
Reviewed-on: https://gerrit.instructure.com/131628
Tested-by: Jenkins
Reviewed-by: Clay Diffrient <cdiffrient@instructure.com>
QA-Review: Tucker McKnight <tmcknight@instructure.com>
Product-Review: Ryan Shaw <ryan@instructure.com>
This commit is contained in:
Ryan Shaw 2017-11-06 16:03:09 -07:00
parent 136635caac
commit 5ceb01d44d
15 changed files with 672 additions and 704 deletions

View File

@ -2306,17 +2306,14 @@ class ApplicationController < ActionController::Base
priority_zones: localized_timezones(I18nTimeZone.us_zones),
timezones: localized_timezones(I18nTimeZone.all)
},
COURSE_ROLES: Role.course_role_data_for_account(@account, @current_user),
URLS: {
USER_LISTS_URL: course_user_lists_url("{{ id }}"),
ENROLL_USERS_URL: course_enroll_users_url("{{ id }}", :format => :json)
}
COURSE_ROLES: Role.course_role_data_for_account(@account, @current_user)
})
js_bundle :account_course_user_search
css_bundle :account_course_user_search
css_bundle :account_course_user_search, :addpeople
@page_title = @account.name
add_crumb '', '?' # the text for this will be set by javascript
js_env({
ROOT_ACCOUNT_NAME: @domain_root_account.name, # used in AddPeopleApp modal
ACCOUNT_ID: @account.id,
ROOT_ACCOUNT_ID: @account.root_account.id,
PERMISSIONS: {

View File

@ -16,163 +16,104 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import preventDefault from 'compiled/fn/preventDefault'
import IconMiniArrowUpSolid from 'instructure-icons/lib/Solid/IconMiniArrowUpSolid'
import IconMiniArrowDownSolid from 'instructure-icons/lib/Solid/IconMiniArrowDownSolid'
import Link from 'instructure-ui/lib/components/Link'
import Tooltip from 'instructure-ui/lib/components/Tooltip'
import React from 'react'
import PropTypes from 'prop-types'
import $ from 'jquery'
import {string, shape, arrayOf, func} from 'prop-types'
import I18n from 'i18n!account_course_user_search'
import axios from 'axios'
import CoursesListRow from './CoursesListRow'
import CoursesListHeader from './CoursesListHeader'
const { string, shape, arrayOf, func } = PropTypes
class CoursesList extends React.Component {
static propTypes = {
courses: arrayOf(shape(CoursesListRow.propTypes)).isRequired,
addUserUrls: shape({
USER_LISTS_URL: string.isRequired,
ENROLL_USERS_URL: string.isRequired,
}).isRequired,
onChangeSort: func.isRequired,
roles: arrayOf(shape({ id: string.isRequired })),
sort: string,
order: string,
};
static defaultProps = {
sort: 'sis_course_id',
order: 'asc',
roles: [],
};
constructor () {
super()
this.state = {
sections: [],
}
}
componentWillMount () {
this.props.courses.forEach((course) => {
axios
.get(`/api/v1/courses/${course.id}/sections`)
.then((response) => {
this.setState({
sections: this.state.sections.concat(response.data)
})
})
})
}
renderHeader ({id, label, tipDesc, tipAsc}) {
return (
<Tooltip
as={Link}
tip={(this.props.sort === id && this.props.order === 'asc') ? tipAsc : tipDesc}
onClick={preventDefault(() => this.props.onChangeSort(id))}
>
{label}
{this.props.sort === id ?
(this.props.order === 'asc' ? <IconMiniArrowDownSolid /> : <IconMiniArrowUpSolid />) :
''
}
</Tooltip>
)
}
render () {
const courses = this.props.courses
return (
<div className="content-box" role="grid">
<div role="row" className="grid-row border border-b pad-box-mini">
<div className="col-xs-3">
<div className="grid-row">
<div className="col-xs-2" />
<div className="col-xs-10" role="columnheader">
{this.renderHeader({
id: 'course_name',
label: I18n.t('Course'),
tipDesc: I18n.t('Click to sort by name ascending'),
tipAsc: I18n.t('Click to sort by name descending')
})}
</div>
export default function CoursesList(props) {
return (
<div className="content-box" role="grid">
<div role="row" className="grid-row border border-b pad-box-mini">
<div className="col-xs-3">
<div className="grid-row">
<div className="col-xs-2" />
<div className="col-xs-10" role="columnheader">
<CoursesListHeader
{...props}
id="course_name"
label={I18n.t('Course')}
tipDesc={I18n.t('Click to sort by name ascending')}
tipAsc={I18n.t('Click to sort by name descending')}
/>
</div>
</div>
<div role="columnheader" className="col-xs-1">
{this.renderHeader({
id: 'sis_course_id',
label: I18n.t('SIS ID'),
tipDesc: I18n.t('Click to sort by SIS ID ascending'),
tipAsc: I18n.t('Click to sort by SIS ID descending')
})}
</div>
<div role="columnheader" className="col-xs-1">
{this.renderHeader({
id: 'term',
label: I18n.t('Term'),
tipDesc: I18n.t('Click to sort by term ascending'),
tipAsc: I18n.t('Click to sort by term descending')
})}
</div>
<div role="columnheader" className="col-xs-2">
{this.renderHeader({
id: 'teacher',
label: I18n.t('Teacher'),
tipDesc: I18n.t('Click to sort by teacher ascending'),
tipAsc: I18n.t('Click to sort by teacher descending')
})}
</div>
<div role="columnheader" className="col-xs-2">
{this.renderHeader({
id: 'subaccount',
label: I18n.t('Sub-Account'),
tipDesc: I18n.t('Click to sort by sub-account ascending'),
tipAsc: I18n.t('Click to sort by sub-account descending')
})}
</div>
<div role="columnheader" className="col-xs-2">
{this.renderHeader({
id: 'enrollments',
label: I18n.t('Enrollments'),
tipDesc: I18n.t('Click to sort by enrollments ascending'),
tipAsc: I18n.t('Click to sort by enrollments descending')
})}
</div>
<div role="columnheader" className="col-xs-1">
<span className="screenreader-only">{I18n.t('Course option links')}</span>
</div>
</div>
<div className="courses-list" role="rowgroup">
{(courses || []).map((course) => {
const urlsForCourse = {
USER_LISTS_URL: $.replaceTags(this.props.addUserUrls.USER_LISTS_URL, 'id', course.id),
ENROLL_USERS_URL: $.replaceTags(this.props.addUserUrls.ENROLL_USERS_URL, 'id', course.id)
}
const courseSections = this.state.sections.filter(section => section.course_id === parseInt(course.id, 10))
return (
<CoursesListRow
key={course.id}
courseModel={courses}
roles={this.props.roles}
urls={urlsForCourse}
sections={courseSections}
{...course}
/>
)
})}
<div role="columnheader" className="col-xs-1">
<CoursesListHeader
{...props}
id="sis_course_id"
label={I18n.t('SIS ID')}
tipDesc={I18n.t('Click to sort by SIS ID ascending')}
tipAsc={I18n.t('Click to sort by SIS ID descending')}
/>
</div>
<div role="columnheader" className="col-xs-1">
<CoursesListHeader
{...props}
id="term"
label={I18n.t('Term')}
tipDesc={I18n.t('Click to sort by term ascending')}
tipAsc={I18n.t('Click to sort by term descending')}
/>
</div>
<div role="columnheader" className="col-xs-2">
<CoursesListHeader
{...props}
id="teacher"
label={I18n.t('Teacher')}
tipDesc={I18n.t('Click to sort by teacher ascending')}
tipAsc={I18n.t('Click to sort by teacher descending')}
/>
</div>
<div role="columnheader" className="col-xs-2">
<CoursesListHeader
{...props}
id="subaccount"
label={I18n.t('Sub-Account')}
tipDesc={I18n.t('Click to sort by sub-account ascending')}
tipAsc={I18n.t('Click to sort by sub-account descending')}
/>
</div>
<div role="columnheader" className="col-xs-2">
<CoursesListHeader
{...props}
id="enrollments"
label={I18n.t('Enrollments')}
tipDesc={I18n.t('Click to sort by enrollments ascending')}
tipAsc={I18n.t('Click to sort by enrollments descending')}
/>
</div>
<div role="columnheader" className="col-xs-1">
<span className="screenreader-only">{I18n.t('Course option links')}</span>
</div>
</div>
)
}
}
export default CoursesList
<div className="courses-list" role="rowgroup">
{(props.courses || []).map(course =>
<CoursesListRow
key={course.id}
courseModel={props.courses}
roles={props.roles}
{...course}
/>
)}
</div>
</div>
)
}
CoursesList.propTypes = {
courses: arrayOf(shape(CoursesListRow.propTypes)).isRequired,
onChangeSort: func.isRequired,
roles: arrayOf(shape({id: string.isRequired})),
sort: string,
order: string
}
CoursesList.defaultProps = {
sort: 'sis_course_id',
order: 'asc',
roles: []
}

View File

@ -0,0 +1,53 @@
/*
* Copyright (C) 2017 - 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 IconMiniArrowUpSolid from 'instructure-icons/lib/Solid/IconMiniArrowUpSolid'
import IconMiniArrowDownSolid from 'instructure-icons/lib/Solid/IconMiniArrowDownSolid'
import Link from 'instructure-ui/lib/components/Link'
import Tooltip from 'instructure-ui/lib/components/Tooltip'
import React from 'react'
import {string} from 'prop-types'
import {pick} from 'lodash'
import preventDefault from 'compiled/fn/preventDefault'
import CoursesList from './CoursesList'
export default function CourseListHeader ({sort, order, onChangeSort, id, label, tipDesc, tipAsc}) {
return (
<Tooltip
as={Link}
tip={sort === id && order === 'asc' ? tipAsc : tipDesc}
onClick={preventDefault(() => onChangeSort(id))}
>
{label}
{sort === id
? (order === 'asc' ? <IconMiniArrowDownSolid /> : <IconMiniArrowUpSolid />)
: ''
}
</Tooltip>
)
}
CourseListHeader.propTypes = {
...pick(CoursesList.propTypes, ['sort', 'order', 'onChangeSort']),
id: string.isRequired,
label: string.isRequired,
tipDesc: string.isRequired,
tipAsc: string.isRequired
}
CourseListHeader.defaultProps = pick(CoursesList.defaultProps, ['sort', 'order'])

View File

@ -19,21 +19,15 @@
import $ from 'jquery'
import React from 'react'
import {number, string, shape, arrayOf} from 'prop-types'
import {Model} from 'Backbone'
import Button from 'instructure-ui/lib/components/Button'
import Tooltip from 'instructure-ui/lib/components/Tooltip'
import IconPlusLine from 'instructure-icons/lib/Line/IconPlusLine'
import IconSettingsLine from 'instructure-icons/lib/Line/IconSettingsLine'
import IconStatsLine from 'instructure-icons/lib/Line/IconStatsLine'
import _ from 'underscore'
import CreateUsersView from 'compiled/views/courses/roster/CreateUsersView'
import RosterUserCollection from 'compiled/collections/RosterUserCollection'
import SectionCollection from 'compiled/collections/SectionCollection'
import RolesCollection from 'compiled/collections/RolesCollection'
import Role from 'compiled/models/Role'
import CreateUserList from 'compiled/models/CreateUserList'
import I18n from 'i18n!account_course_user_search'
import UserLink from './UserLink'
import AddPeopleApp from '../add_people/add_people_app'
const uniqueTeachers = teachers => _.uniq(teachers, teacher => teacher.id)
@ -44,97 +38,70 @@ export default class CoursesListRow extends React.Component {
workflow_state: string.isRequired,
total_students: number.isRequired,
teachers: arrayOf(shape(UserLink.propTypes)).isRequired,
sis_course_id: string.isRequired,
sis_course_id: string,
subaccount_name: string.isRequired,
term: shape({name: string.isRequired}).isRequired,
urls: shape({
ENROLL_USERS_URL: string.isRequired,
USER_LISTS_URL: string.isRequired
}),
roles: arrayOf(shape({id: string.isRequired})),
sections: arrayOf(shape(UserLink.propTypes))
}
static defaultProps = {
roles: [],
urls: {ENROLL_USERS_URL: '', USER_LISTS_URL: ''},
sections: []
roles: []
}
constructor(props) {
super(props)
const teachers = uniqueTeachers(props.teachers)
this.state = {
newlyEnrolledStudents: 0,
teachersToShow: _.compact([teachers[0], teachers[1]])
}
}
showMoreLink = () => {
if (this.props.teachers.length > 2 && this.state.teachersToShow.length === 2) {
return (
<Button variant="link" onClick={this.showMoreTeachers}>
{I18n.t('Show More')}
</Button>
)
getSections = () =>
this.promiseToGetSections ||
(this.promiseToGetSections = $.get(`/api/v1/courses/${this.props.id}/sections?per_page=100`))
handleNewEnrollments = newEnrollments => {
if (newEnrollments && newEnrollments.length) {
$.flashMessage( I18n.t( {
one: '%{user_name} successfully enrolled into *%{course_name}*.',
other: '%{count} people successfully enrolled into *%{course_name}*.'
},{
count: newEnrollments.length,
user_name: newEnrollments[0].enrollment.name,
course_name: this.props.name,
wrappers: [
`<a href="/courses/${this.props.id}">$1</a>`
]
}))
const newStudents = newEnrollments.filter(e => e.enrollment.type === 'StudentEnrollment')
this.setState({newlyEnrolledStudents: this.state.newlyEnrolledStudents + newStudents.length})
}
}
openAddUsersToCourseDialog = () => {
this.getSections().then(sections => {
this.addPeopleApp = this.addPeopleApp || new AddPeopleApp($('<div />')[0], {
courseId: this.props.id,
courseName: this.props.name,
defaultInstitutionName: ENV.ROOT_ACCOUNT_NAME || '',
roles: (this.props.roles || []).filter(role => role.manageable_by_user),
sections,
onClose: () => {
this.handleNewEnrollments(this.addPeopleApp.usersHaveBeenEnrolled())
},
inviteUsersURL: `/courses/${this.props.id}/invite_users`,
canReadSIS: true // Since we show course SIS ids in search results, I assume anyone that gets here can read SIS
})
this.addPeopleApp.open()
})
}
showMoreTeachers = () => {
this.setState({teachersToShow: uniqueTeachers(this.props.teachers)})
}
addUserToCourse = () => {
const course = new Model({id: this.props.id})
const userCollection = new RosterUserCollection(null, {
course_id: this.props.id,
sections: new SectionCollection(this.props.sections),
params: {
include: ['avatar_url', 'enrollments', 'email', 'observed_users', 'can_be_removed'],
per_page: 50
}
})
userCollection.fetch()
userCollection.once('reset', () => {
userCollection.on('reset', () => {
const numUsers = userCollection.length
let msg = ''
if (numUsers === 0) {
msg = I18n.t('No matching users found.')
} else if (numUsers === 1) {
msg = I18n.t('1 user found.')
} else {
msg = I18n.t('%{userCount} users found.', {userCount: numUsers})
}
$('#aria_alerts').empty().text(msg)
})
})
const createUsersViewParams = {
collection: userCollection,
rolesCollection: new RolesCollection(this.props.roles.map(role => new Role(role))),
model: new CreateUserList({
sections: this.props.sections,
roles: this.props.roles,
readURL: this.props.urls.USER_LISTS_URL,
updateURL: this.props.urls.ENROLL_USERS_URL
}),
courseModel: course,
title: I18n.t('Add People'),
height: 520,
className: 'form-dialog'
}
const createUsersBackboneView = new CreateUsersView(createUsersViewParams)
createUsersBackboneView.open()
createUsersBackboneView.on('close', () => {
createUsersBackboneView.remove()
})
}
render() {
const {id, name, workflow_state, sis_course_id, total_students, subaccount_name} = this.props
const url = `/courses/${id}`
@ -152,39 +119,41 @@ export default class CoursesListRow extends React.Component {
)}
</div>
<div className="col-xs-10">
<div className="courseName">
<a href={url}>{name}</a>
</div>
<a href={url}>{name}</a>
</div>
</div>
</div>
<div className="col-xs-1" role="gridcell">
<div className="courseSIS">{sis_course_id}</div>
{sis_course_id}
</div>
<div className="col-xs-1" role="gridcell">
<div className="courseSIS">{this.props.term.name}</div>
{this.props.term.name}
</div>
<div className="col-xs-2" role="gridcell">
{this.state.teachersToShow && this.state.teachersToShow.map(teacher =>
{(this.state.teachersToShow || []).map(teacher =>
<UserLink key={teacher.id} {...teacher} />
)}
{this.showMoreLink()}
{this.props.teachers.length > 2 && this.state.teachersToShow.length === 2 &&
<Button variant="link" onClick={this.showMoreTeachers}>
{I18n.t('Show More')}
</Button>
}
</div>
<div className="col-xs-2" role="gridcell">
<div className="courseSubaccount">{subaccount_name}</div>
{subaccount_name}
</div>
<div className="col-xs-1" role="gridcell">
<div className="totalStudents">{I18n.n(total_students)}</div>
{I18n.n(total_students + this.state.newlyEnrolledStudents)}
</div>
<div className="col-xs-2" role="gridcell">
<div className="courses-user-list-actions">
<Tooltip tip={I18n.t('Add Users to %{name}', {name})}>
<Button variant="icon" size="small" onClick={this.addUserToCourse}>
<Button variant="icon" size="small" onClick={this.openAddUsersToCourseDialog}>
<IconPlusLine />
</Button>
</Tooltip>

View File

@ -43,10 +43,6 @@ const defaultFilters = {
class CoursesPane extends React.Component {
static propTypes = {
roles: arrayOf(shape({ id: string.isRequired })).isRequired,
addUserUrls: shape({
USER_LISTS_URL: string.isRequired,
ENROLL_USERS_URL: string.isRequired,
}).isRequired,
queryParams: shape().isRequired,
onUpdateQueryParams: func.isRequired,
accountId: string.isRequired
@ -171,7 +167,6 @@ class CoursesPane extends React.Component {
accountId={this.props.accountId}
courses={courses.data}
roles={this.props.roles}
addUserUrls={this.props.addUserUrls}
sort={filters.sort}
order={filters.order}
/>

View File

@ -26,77 +26,77 @@ import { actions, actionTypes } from './actions'
import reducer from './reducer'
import AddPeople from './components/add_people'
class AddPeopleApp {
constructor (root, props) {
this.root = root; // DOM node we render into
this.closer = this.close.bind(this); // close us
this.onCloseCallback = props.onClose; // tell our parent
this.theme = props.theme || 'canvas';
export default class AddPeopleApp {
constructor (root, props) {
this.root = root; // DOM node we render into
this.closer = this.close.bind(this); // close us
this.onCloseCallback = props.onClose; // tell our parent
this.theme = props.theme || 'canvas';
// natural sort the sections by name
let sections = props.sections || [];
sections = sections.slice().sort(natcompare.byKey('name'));
// natural sort the sections by name
let sections = props.sections || [];
sections = sections.slice().sort(natcompare.byKey('name'));
// create the store with its initial state
// some values are default, some come from props
this.store = createStore(reducer, {
courseParams: {
courseId: props.courseId || 0,
defaultInstitutionName: props.defaultInstitutionName || '',
roles: props.roles || [],
sections,
inviteUsersURL: props.inviteUsersURL
},
inputParams: {
searchType: defaultState.inputParams.searchType,
nameList: defaultState.inputParams.nameList,
role: props.roles.length ? props.roles[0].id : '',
section: sections.length ? sections[0].id : '',
canReadSIS: props.canReadSIS
},
apiState: defaultState.apiState,
userValidationResult: defaultState.userValidationResult,
usersToBeEnrolled: defaultState.usersToBeEnrolled
});
// create the store with its initial state
// some values are default, some come from props
this.store = createStore(reducer, {
courseParams: {
courseId: props.courseId || 0,
courseName: props.courseName || '',
defaultInstitutionName: props.defaultInstitutionName || '',
roles: props.roles || [],
sections,
inviteUsersURL: props.inviteUsersURL
},
inputParams: {
searchType: defaultState.inputParams.searchType,
nameList: defaultState.inputParams.nameList,
role: props.roles.length ? props.roles[0].id : '',
section: sections.length ? sections[0].id : '',
canReadSIS: props.canReadSIS
},
apiState: defaultState.apiState,
userValidationResult: defaultState.userValidationResult,
usersToBeEnrolled: defaultState.usersToBeEnrolled
});
// when ConnectedApp is rendered, these state members are passed as props
function mapStateToProps (state) {
return { ...state };
}
// when ConnectedApp is rendered, all the action dispatch functions are passed as props
const mapDispatchToProps = dispatch => bindActionCreators(actions, dispatch)
// connect our top-level component to redux
this.ConnectedApp = connect(mapStateToProps, mapDispatchToProps)(AddPeople);
// when ConnectedApp is rendered, these state members are passed as props
function mapStateToProps (state) {
return { ...state };
}
open () {
this.render(true);
}
close () {
this.render(false);
if (typeof this.onCloseCallback === 'function') {
this.onCloseCallback();
}
}
// used by the roster page to decide if it has to requry for the course's
// enrollees
usersHaveBeenEnrolled () {
return this.store.getState().usersEnrolled;
}
unmount () {
ReactDOM.unmountComponentAtNode(this.root);
}
render (isOpen) {
const ConnectedApp = this.ConnectedApp;
ReactDOM.render(
<Provider store={this.store}>
<ConnectedApp isOpen={isOpen} onClose={this.closer} theme={this.theme} />
</Provider>,
this.root
)
// when ConnectedApp is rendered, all the action dispatch functions are passed as props
const mapDispatchToProps = dispatch => bindActionCreators(actions, dispatch)
// connect our top-level component to redux
this.ConnectedApp = connect(mapStateToProps, mapDispatchToProps)(AddPeople);
}
open () {
this.render(true);
}
close () {
this.render(false);
if (typeof this.onCloseCallback === 'function') {
this.onCloseCallback();
}
}
export default AddPeopleApp
// used by the roster page to decide if it has to requry for the course's
// enrollees
usersHaveBeenEnrolled () {
return this.store.getState().usersEnrolled;
}
unmount () {
ReactDOM.unmountComponentAtNode(this.root);
}
render (isOpen) {
const ConnectedApp = this.ConnectedApp;
ReactDOM.render(
<Provider store={this.store}>
<ConnectedApp isOpen={isOpen} onClose={this.closer} theme={this.theme} />
</Provider>,
this.root
)
}
}

View File

@ -18,311 +18,331 @@
import I18n from 'i18n!roster'
import React from 'react'
import PropTypes from 'prop-types'
import {bool, func, shape, arrayOf, oneOfType} from 'prop-types'
import Modal, {ModalHeader, ModalBody, ModalFooter} from 'instructure-ui/lib/components/Modal'
import Heading from 'instructure-ui/lib/components/Heading'
import Button from 'instructure-ui/lib/components/Button'
import Spinner from 'instructure-ui/lib/components/Spinner'
import ScreenReaderContent from 'instructure-ui/lib/components/ScreenReaderContent'
import {courseParamsShape, apiStateShape, inputParamsShape, validateResultShape, personReadyToEnrollShape} from './shapes'
import {
courseParamsShape,
apiStateShape,
inputParamsShape,
validateResultShape,
personReadyToEnrollShape,
newUserShape
} from './shapes'
import PeopleSearch from './people_search'
import PeopleReadyList from './people_ready_list'
import PeopleValidationIssues from './people_validation_issues'
import APIError from './api_error'
const PEOPLESEARCH = 'peoplesearch';
const PEOPLEREADYLIST = 'peoplereadylist';
const PEOPLEVALIDATIONISSUES = 'peoplevalidationissues';
const RESULTPENDING = 'resultpending';
const APIERROR = 'apierror';
const PEOPLESEARCH = 'peoplesearch'
const PEOPLEREADYLIST = 'peoplereadylist'
const PEOPLEVALIDATIONISSUES = 'peoplevalidationissues'
const RESULTPENDING = 'resultpending'
const APIERROR = 'apierror'
// @param props: the component's properties to consider
// @returns true if our user has dealt with all the duplicates and missing
// search results
function arePeopleValidationIssuesResolved (props) {
function isReadyToCreate (candidate) {
return !!(candidate.createNew && candidate.newUserInfo
&& candidate.newUserInfo.email); // newUserInfo.name is now optional
const isReadyToCreate = candidate =>
!!(candidate.createNew && candidate.newUserInfo && candidate.newUserInfo.email) // newUserInfo.name is now optional
// @param props: the component's properties to consider
// @returns true if our user has dealt with all the duplicates and missing
// search results
function arePeopleValidationIssuesResolved(props) {
let found = Object.keys(props.userValidationResult.duplicates).find(address => {
const dupe = props.userValidationResult.duplicates[address]
return !(dupe.selectedUserId >= 0 || dupe.skip || isReadyToCreate(dupe))
})
if (found) return false
found = Object.keys(props.userValidationResult.missing).find(address => {
const miss = props.userValidationResult.missing[address]
return miss.createNew && !isReadyToCreate(miss)
})
if (found) return false
return true
}
export default class AddPeople extends React.Component {
// TODO: deal with defaut props after the warmfix to keep this change small
/* eslint-disable react/require-default-props */
static propTypes = {
isOpen: bool,
validateUsers: func.isRequired,
enrollUsers: func.isRequired,
onClose: func,
// these props are generated from store state
courseParams: shape(courseParamsShape),
apiState: shape(apiStateShape),
inputParams: shape(inputParamsShape),
userValidationResult: shape(validateResultShape),
usersToBeEnrolled: arrayOf(shape(personReadyToEnrollShape)),
// these are props generated from actions
setInputParams: func,
chooseDuplicate: func,
enqueueNewForDuplicate: func,
skipDuplicate: func,
enqueueNewForMissing: func,
resolveValidationIssues: func,
reset: func,
// eslint-disable-next-line react/no-unused-prop-types
usersEnrolled: oneOfType([
bool,
arrayOf(
shape({
enrollment: shape(newUserShape)
})
)
]) // it IS used in componentWillReceiveProps.
}
/* eslint-enable */
constructor(props) {
super(props)
this.state = {
currentPage: PEOPLESEARCH, // the page to render
focusToTop: false // move focus to the top of the panel
}
this.content = null
}
componentDidMount() {
this.manageFocus()
}
componentWillReceiveProps(nextProps) {
if (nextProps.usersEnrolled) this.close()
}
componentDidUpdate() {
this.manageFocus()
}
let found = Object.keys(props.userValidationResult.duplicates).find((address) => {
const dupe = props.userValidationResult.duplicates[address];
return !(dupe.selectedUserId >= 0 || dupe.skip || isReadyToCreate(dupe))
// event handlers ---------------------
// search input changes
onChangeSearchInput = newValue => {
const inputParams = Object.assign({}, this.props.inputParams, newValue)
this.props.setInputParams(inputParams)
}
// dispensation of duplicate results change
onChangeDuplicate = newValues => {
if ('selectedUserId' in newValues) {
// our user chose one of the duplicates
this.props.chooseDuplicate({
address: newValues.address,
user_id: newValues.selectedUserId
})
} else if ('newUserInfo' in newValues) {
// our chose to create a new user instead of choosing a duplicate
this.props.enqueueNewForDuplicate({
address: newValues.address,
newUserInfo: newValues.newUserInfo
})
} else if (newValues.skip) {
// our user chose to skip these duplicates
this.props.skipDuplicate(newValues.address)
}
}
// our user is updating the new user data for a missing result
onChangeMissing = ({address, newUserInfo}) => {
this.props.enqueueNewForMissing({address, newUserInfo})
}
// for a11y, whenever the user changes panels, move focus to the top of the content
manageFocus() {
if (this.state.focusToTop) {
if (this.content) this.content.focus()
this.setState({focusToTop: false})
}
}
// modal next and back handlers ---------------------
// on next callback from PeopleSearch page
searchNext = () => {
this.setState({currentPage: PEOPLEVALIDATIONISSUES, focusToTop: true})
this.props.validateUsers()
}
// on next callback from PeopleValidationIssues page
validationIssuesNext = () => {
this.setState({currentPage: PEOPLEREADYLIST, focusToTop: true})
this.props.resolveValidationIssues()
}
// on next callback from the ready list of users
enrollUsers = () => {
this.props.enrollUsers()
}
// we're finished. close up shop.
close = () => {
this.setState({
currentPage: PEOPLESEARCH,
focusToTop: true
})
if (found) return false
found = Object.keys(props.userValidationResult.missing).find((address) => {
const miss = props.userValidationResult.missing[address];
return miss.createNew && !isReadyToCreate(miss);
});
if (found) return false;
return true;
if (typeof this.props.onClose === 'function') this.props.onClose()
this.props.reset()
}
class AddPeople extends React.Component {
// TODO: deal with defaut props after the warmfix to keep this change small
/* eslint-disable react/require-default-props */
static propTypes = {
isOpen: PropTypes.bool,
validateUsers: PropTypes.func.isRequired,
enrollUsers: PropTypes.func.isRequired,
onClose: PropTypes.func,
// these props are generated from store state
courseParams: PropTypes.shape(courseParamsShape),
apiState: PropTypes.shape(apiStateShape),
inputParams: PropTypes.shape(inputParamsShape),
userValidationResult: PropTypes.shape(validateResultShape),
usersToBeEnrolled: PropTypes.arrayOf(PropTypes.shape(personReadyToEnrollShape)),
// these are props generated from actions
setInputParams: PropTypes.func,
chooseDuplicate: PropTypes.func,
enqueueNewForDuplicate: PropTypes.func,
skipDuplicate: PropTypes.func,
enqueueNewForMissing: PropTypes.func,
resolveValidationIssues: PropTypes.func,
reset: PropTypes.func,
usersEnrolled: PropTypes.bool // eslint-disable-line react/no-unused-prop-types
// it IS used in componentWillReceiveProps.
};
/* eslint-enable */
constructor (props) {
super(props);
this.state = {
currentPage: PEOPLESEARCH, // the page to render
focusToTop: false // move focus to the top of the panel
};
this.content = null;
}
componentDidMount () {
this.manageFocus();
}
componentWillReceiveProps (nextProps) {
if (nextProps.usersEnrolled) {
this.close();
}
}
componentDidUpdate () {
this.manageFocus();
}
// event handlers ---------------------
// search input changes
onChangeSearchInput = (newValue) => {
const inputParams = Object.assign({}, this.props.inputParams, newValue);
this.props.setInputParams(inputParams);
}
// dispensation of duplicate results change
onChangeDuplicate = (newValues) => {
if ('selectedUserId' in newValues) {
// our user chose one of the duplicates
this.props.chooseDuplicate({
address: newValues.address,
user_id: newValues.selectedUserId
});
} else if ('newUserInfo' in newValues) {
// our chose to create a new user instead of chosing a duplicate
this.props.enqueueNewForDuplicate({
address: newValues.address,
newUserInfo: newValues.newUserInfo
});
} else if (newValues.skip) {
// our user chose to skip these duplicates
this.props.skipDuplicate(newValues.address);
}
}
// our user is updating the new user data for a missing result
onChangeMissing = ({address, newUserInfo}) => {
this.props.enqueueNewForMissing({address, newUserInfo});
}
// for a11y, whenever the user changes panels, move focus to the top of the content
manageFocus () {
if (this.state.focusToTop) {
if (this.content) {
this.content.focus();
}
this.setState({focusToTop: false});
}
}
// modal next and back handlers ---------------------
// on next callback from PeopleSearch page
searchNext = () => {
this.setState({currentPage: PEOPLEVALIDATIONISSUES, focusToTop: true});
this.props.validateUsers();
}
// on next callback from PeopleValidationIssues page
validationIssuesNext = () => {
this.setState({currentPage: PEOPLEREADYLIST, focusToTop: true});
this.props.resolveValidationIssues();
}
// on next callback from the ready list of users
enrollUsers = () => {
this.props.enrollUsers();
}
// we're finished. close up shop.
close = () => {
this.setState({
currentPage: PEOPLESEARCH,
focusToTop: true
});
if (typeof this.props.onClose === 'function') {
this.props.onClose();
}
this.props.reset();
}
// go back to a previous panel in the modal
// @param pagename: name of the panel to return to
// @param stateResets: arrayOf(string): which of the state sub-sections
// should get reset to default values.
// undefined implies all
goBack (pagename, stateResets) {
this.props.reset(stateResets);
this.setState({currentPage: pagename, focusToTop: true});
}
// different panels go back slightly differently
apiErrorOnBack = () => {
this.goBack(PEOPLESEARCH, []);
}
peopleReadyOnBack = () => {
this.goBack(PEOPLESEARCH, undefined);
}
peopleValidationIssuesOnBack = () => {
this.goBack(PEOPLESEARCH, ['userValidationResult']);
}
// rendering -------------------------------------
render () {
// this.state.currentPage is the requested page,
// but it may get overridden
let currentPage = this.state.currentPage;
if (this.props.apiState.pendingCount) {
// api call is in-flight
currentPage = RESULTPENDING;
} else if (this.props.apiState.error) {
// api call returned an error
currentPage = APIERROR;
} else if (PEOPLEVALIDATIONISSUES === currentPage
&& Object.keys(this.props.userValidationResult.missing).length === 0
&& Object.keys(this.props.userValidationResult.duplicates).length === 0) {
// user initiated the search, so we plan on going to the validation page,
// but if the search returned nothing but unique and valid users, then
// we can skip ahead
currentPage = PEOPLEREADYLIST;
}
let currentPanel = null; // component in the modal's body
let onNext = null; // callback on Next button
let nextLabel = I18n.t('Next'); // label on Next button
let readyForNext = false; // is the Next button enabled?
let onBack = null; // callback on the back button
let backLabel = I18n.t('Back'); // label on eh Back button
const cancelLabel = I18n.t('Cancel'); // label on the cancel button
let panelLabel = ''; // tell SR user what this panel is for
let panelDescription = ''; // tell SR user more info
switch (currentPage) {
case RESULTPENDING:
currentPanel = <Spinner size="medium" title={I18n.t('Loading')} />;
panelLabel = I18n.t('loading');
break;
case APIERROR:
currentPanel = <APIError error={this.props.apiState.error} />
onBack = this.apiErrorOnBack;
panelLabel = I18n.t('error')
break;
case PEOPLESEARCH:
default:
currentPanel = (
<PeopleSearch
{...this.props.inputParams}
{...this.props.courseParams}
onChange={this.onChangeSearchInput}
/>
);
onNext = this.searchNext;
readyForNext = this.props.inputParams.nameList.length > 0;
panelLabel = I18n.t('User search panel');
panelDescription = I18n.t('Use this panel to search for people you wish to add to this course.');
break;
case PEOPLEVALIDATIONISSUES:
currentPanel = (
<PeopleValidationIssues
{...this.props.userValidationResult}
searchType={this.props.inputParams.searchType}
inviteUsersURL={this.props.courseParams.inviteUsersURL}
onChangeDuplicate={this.onChangeDuplicate}
onChangeMissing={this.onChangeMissing}
/>
);
onNext = this.validationIssuesNext;
onBack = this.peopleValidationIssuesOnBack;
readyForNext = arePeopleValidationIssuesResolved(this.props);
panelLabel = I18n.t('User vaildation issues panel');
panelDescription = I18n.t('Use this panel to resolve duplicate results or people not found with your search.');
break;
case PEOPLEREADYLIST:
currentPanel = (
<PeopleReadyList
nameList={this.props.usersToBeEnrolled}
defaultInstitutionName={this.props.courseParams.defaultInstitutionName}
canReadSIS={this.props.inputParams.canReadSIS}
/>
);
onNext = this.enrollUsers;
onBack = this.peopleReadyOnBack;
backLabel = I18n.t('Start Over');
nextLabel = I18n.t('Add Users');
readyForNext = this.props.usersToBeEnrolled.length > 0;
panelLabel = I18n.t('Ready to enroll panel');
panelDescription = I18n.t('This panel lists the users ready to be added to this course.');
break;
}
return (
<Modal
closeButtonLabel={cancelLabel}
id="add_people_modal"
open={this.props.isOpen}
label={I18n.t('Modal Dialog: Add People')}
applicationElement={() => document.getElementById('application')}
onDismiss={this.close}
ref={(node) => { this.node = node; }}
shouldCloseOnOverlayClick={false}
size="medium"
tabIndex="-1"
>
<ModalHeader>
<Heading tabIndex="-1">{I18n.t('Add People')}</Heading>
</ModalHeader>
<ModalBody>
<div
className="addpeople"
tabIndex="-1"
ref={(elem) => { this.content = elem }}
aria-label={panelLabel}
aria-describedby="addpeople_panelDescription"
>
<ScreenReaderContent id="addpeople_panelDescription">{panelDescription}</ScreenReaderContent>
{currentPanel}
</div>
</ModalBody>
<ModalFooter>
<Button id="addpeople_cancel" onClick={this.close}>{cancelLabel}</Button>
&nbsp;{onBack ? <Button id="addpeople_back" onClick={onBack}>{backLabel}</Button> : null}
&nbsp;{onNext
? <Button id="addpeople_next" onClick={onNext} variant="primary" disabled={!readyForNext}>{nextLabel}</Button>
: null}
</ModalFooter>
</Modal>
);
}
// go back to a previous panel in the modal
// @param pagename: name of the panel to return to
// @param stateResets: arrayOf(string): which of the state sub-sections
// should get reset to default values.
// undefined implies all
goBack(pagename, stateResets) {
this.props.reset(stateResets)
this.setState({currentPage: pagename, focusToTop: true})
}
// different panels go back slightly differently
apiErrorOnBack = () => {
this.goBack(PEOPLESEARCH, [])
}
peopleReadyOnBack = () => {
this.goBack(PEOPLESEARCH, undefined)
}
peopleValidationIssuesOnBack = () => {
this.goBack(PEOPLESEARCH, ['userValidationResult'])
}
export default AddPeople
// rendering -------------------------------------
render() {
// this.state.currentPage is the requested page,
// but it may get overridden
let currentPage = this.state.currentPage
if (this.props.apiState.pendingCount) {
// api call is in-flight
currentPage = RESULTPENDING
} else if (this.props.apiState.error) {
// api call returned an error
currentPage = APIERROR
} else if (
PEOPLEVALIDATIONISSUES === currentPage &&
Object.keys(this.props.userValidationResult.missing).length === 0 &&
Object.keys(this.props.userValidationResult.duplicates).length === 0
) {
// user initiated the search, so we plan on going to the validation page,
// but if the search returned nothing but unique and valid users, then
// we can skip ahead
currentPage = PEOPLEREADYLIST
}
let currentPanel = null // component in the modal's body
let onNext = null // callback on Next button
let nextLabel = I18n.t('Next') // label on Next button
let readyForNext = false // is the Next button enabled?
let onBack = null // callback on the back button
let backLabel = I18n.t('Back') // label on eh Back button
const cancelLabel = I18n.t('Cancel') // label on the cancel button
let panelLabel = '' // tell SR user what this panel is for
let panelDescription = '' // tell SR user more info
switch (currentPage) {
case RESULTPENDING:
currentPanel = <Spinner size="medium" title={I18n.t('Loading')} />
panelLabel = I18n.t('loading')
break
case APIERROR:
currentPanel = <APIError error={this.props.apiState.error} />
onBack = this.apiErrorOnBack
panelLabel = I18n.t('error')
break
case PEOPLESEARCH:
default:
currentPanel = (
<PeopleSearch
{...this.props.inputParams}
{...this.props.courseParams}
onChange={this.onChangeSearchInput}
/>
)
onNext = this.searchNext
readyForNext = this.props.inputParams.nameList.length > 0
panelLabel = I18n.t('User search panel')
panelDescription = I18n.t('Use this panel to search for people you wish to add to this course.')
break
case PEOPLEVALIDATIONISSUES:
currentPanel = (
<PeopleValidationIssues
{...this.props.userValidationResult}
searchType={this.props.inputParams.searchType}
inviteUsersURL={this.props.courseParams.inviteUsersURL}
onChangeDuplicate={this.onChangeDuplicate}
onChangeMissing={this.onChangeMissing}
/>
)
onNext = this.validationIssuesNext
onBack = this.peopleValidationIssuesOnBack
readyForNext = arePeopleValidationIssuesResolved(this.props)
panelLabel = I18n.t('User vaildation issues panel')
panelDescription = I18n.t('Use this panel to resolve duplicate results or people not found with your search.')
break
case PEOPLEREADYLIST:
currentPanel = (
<PeopleReadyList
nameList={this.props.usersToBeEnrolled}
defaultInstitutionName={this.props.courseParams.defaultInstitutionName}
canReadSIS={this.props.inputParams.canReadSIS}
/>
)
onNext = this.enrollUsers
onBack = this.peopleReadyOnBack
backLabel = I18n.t('Start Over')
nextLabel = I18n.t('Add Users')
readyForNext = this.props.usersToBeEnrolled.length > 0
panelLabel = I18n.t('Ready to enroll panel')
panelDescription = I18n.t('This panel lists the users ready to be added to this course.')
break
}
const modalTitle = this.props.courseParams.courseName
? I18n.t('Add People to: %{courseName}', {courseName: this.props.courseParams.courseName})
: I18n.t('Add People')
return (
<Modal
closeButtonLabel={cancelLabel}
id="add_people_modal"
open={this.props.isOpen}
label={modalTitle}
applicationElement={() => document.getElementById('application')}
onDismiss={this.close}
ref={node => { this.node = node }}
shouldCloseOnOverlayClick={false}
size="medium"
tabIndex="-1"
>
<ModalHeader>
<Heading tabIndex="-1">{modalTitle}</Heading>
</ModalHeader>
<ModalBody>
<div
className="addpeople"
tabIndex="-1"
ref={elem => { this.content = elem }}
aria-label={panelLabel}
aria-describedby="addpeople_panelDescription"
>
<ScreenReaderContent id="addpeople_panelDescription">
{panelDescription}
</ScreenReaderContent>
{currentPanel}
</div>
</ModalBody>
<ModalFooter>
<Button id="addpeople_cancel" onClick={this.close}>
{cancelLabel}
</Button>
&nbsp;
{onBack && (
<Button id="addpeople_back" onClick={onBack}>
{backLabel}
</Button>
)}
&nbsp;
{onNext && (
<Button id="addpeople_next" onClick={onNext} variant="primary" disabled={!readyForNext}>
{nextLabel}
</Button>
)}
</ModalFooter>
</Modal>
)
}
}

View File

@ -22,6 +22,6 @@ import {actionTypes} from '../actions'
import '../store'
export default handleActions({
[actionTypes.ENROLL_USERS_SUCCESS]: (/* state, action */) => true,
[actionTypes.ENROLL_USERS_SUCCESS]: (state, action) => action.payload,
[actionTypes.RESET]: (/* state, action */) => false
}, false)

View File

@ -0,0 +1,42 @@
// these are the styles for the Add people dialog
.addpeople {
.addpeople__peoplesearch {
fieldset {
margin-top:1.5em;
}
fieldset.peoplesearch__selections {
margin-right: auto;
margin-left: auto;
margin-top: .5rem;
text-align: center;
&>div {
display: flex;
justify-content: center;
}
.peoplesearch__selection {
text-align: left;
margin: 0 1em;
flex: 0 0 auto;
width: 30%;
}
}
.peoplesearch__instructions {
margin: 1.5em 0;
text-align: center;
.usericon {
margin: 0 auto;
font-size: 48px;
line-height: 50px;
}
}
}
.namelist {
margin: 1.5em 0;
}
.peoplevalidationissues__missing tr td:first-child {
width: 30px;
}
&:focus {
outline: none;
}
}

View File

@ -65,46 +65,3 @@
margin-top: $ic-sp/2;
}
}
/* Add People */
.addpeople {
.addpeople__peoplesearch {
fieldset {
margin-top:1.5em;
}
fieldset.peoplesearch__selections {
margin-right: auto;
margin-left: auto;
margin-top: .5rem;
text-align: center;
&>div {
display: flex;
justify-content: center;
}
.peoplesearch__selection {
text-align: left;
margin: 0 1em;
flex: 0 0 auto;
width: 30%;
}
}
.peoplesearch__instructions {
margin: 1.5em 0;
text-align: center;
.usericon {
margin: 0 auto;
font-size: 48px;
line-height: 50px;
}
}
}
.namelist {
margin: 1.5em 0;
}
.peoplevalidationissues__missing tr td:first-child {
width: 30px;
}
}
.addpeople:focus {
outline: none;
}

View File

@ -23,7 +23,7 @@
translated_title = @context.is_a?(Course) ? t('Course Roster') : t('Group Roster')
content_for :page_title, join_title(translated_title, @context.name)
css_bundle :roster
css_bundle :roster, :addpeople
is_teacher = can_do(@context, @current_user, :manage_students)
%>

View File

@ -17,7 +17,8 @@
*/
import React from 'react'
import {shallow} from 'enzyme'
import ReactDOM from 'react-dom'
import {shallow, mount} from 'enzyme'
import CoursesList from 'jsx/account_course_user_search/CoursesList'
import CoursesListRow from 'jsx/account_course_user_search/CoursesListRow'
@ -37,10 +38,6 @@ const props = {
name: "Testing Term"
}
}],
addUserUrls: {
USER_LISTS_URL: 'http://courses/{{id}}/users',
ENROLL_USERS_URL: 'http://courses/{{id}}/users/enroll'
},
roles: [{
id: '1',
course_id: '1',
@ -50,17 +47,6 @@ const props = {
}]
}
test('renders with the proper urls and roles', () => {
const wrapper = shallow(<CoursesList {...props} />)
const renderedList = wrapper.find(CoursesListRow)
const renderedUrls = renderedList.props().urls;
deepEqual(renderedUrls, {
USER_LISTS_URL: 'http://courses/123/users',
ENROLL_USERS_URL: 'http://courses/123/users/enroll'
}, 'it passed url props in and they were replaced properly');
});
QUnit.module('Account Course User Search CoursesList Sorting');
const coursesProps = {
@ -137,10 +123,6 @@ const coursesProps = {
name: "Dz Term"
}
}],
addUserUrls: {
USER_LISTS_URL: 'http://courses/{{id}}/users',
ENROLL_USERS_URL: 'http://courses/{{id}}/users/enroll'
},
roles: [{
id: '1',
course_id: '1',
@ -162,51 +144,53 @@ Object.entries({
}).forEach(([columnID, label]) => {
test(`sorting by ${columnID} asc puts down-arrow on ${label} only`, () => {
const wrapper = shallow(<CoursesList {...{
const wrapper = mount(<CoursesList {...{
...coursesProps,
sort: columnID,
order: 'asc'
}} />)
equal(wrapper.find('IconMiniArrowUpSolid').length, 0, 'no columns have an up arrow')
const icons = wrapper.find('IconMiniArrowDownSolid')
}} />).getDOMNode()
equal(wrapper.querySelectorAll('svg[name="IconMiniArrowUpSolid"]').length, 0, 'no columns have an up arrow')
const icons = wrapper.querySelectorAll('svg[name=IconMiniArrowDownSolid]')
equal(icons.length, 1, 'only one down arrow')
const header = icons.first().parents('Tooltip')
let expectedTip = `Click to sort by ${label} descending`
if (columnID === 'course_name') {
expectedTip = 'Click to sort by name descending'
}
ok(header.prop('tip').match(RegExp(expectedTip, 'i')), 'has right tooltip')
ok(header.contains(label), `${label} is the one that has the down arrow`)
const header = icons[0].parentNode
const expectedTip = (columnID === 'course_name')
? 'Click to sort by name descending'
: `Click to sort by ${label} descending`
ok(header.textContent.match(RegExp(expectedTip, 'i')), 'has right tooltip')
ok(header.textContent.match(label), `${label} is the one that has the down arrow`)
})
test(`sorting by ${columnID} desc puts up-arrow on ${label} only`, () => {
const wrapper = shallow(<CoursesList {...{
const wrapper = mount(<CoursesList {...{
...coursesProps,
sort: columnID,
order: 'desc'
}} />)
equal(wrapper.find('IconMiniArrowDownSolid').length, 0)
const icons = wrapper.find('IconMiniArrowUpSolid', 'no columns have a down arrow')
}} />).getDOMNode()
equal(wrapper.querySelectorAll('svg[name=IconMiniArrowDownSolid]').length, 0)
const icons = wrapper.querySelectorAll('svg[name=IconMiniArrowUpSolid]', 'no columns have a down arrow')
equal(icons.length, 1, 'only one up arrow')
const header = icons.first().parents('Tooltip')
let expectedTip = `Click to sort by ${label} ascending`
if (columnID === 'course_name') {
expectedTip = 'Click to sort by name ascending'
}
ok(header.prop('tip').match(RegExp(expectedTip, 'i')), 'has right tooltip')
ok(header.contains(label), `${label} is the one that has the up arrow`)
const header = icons[0].parentNode
const expectedTip = (columnID === 'course_name')
? 'Click to sort by name ascending'
: `Click to sort by ${label} ascending`
ok(header.textContent.match(RegExp(expectedTip, 'i')), 'has right tooltip')
ok(header.textContent.match(label), `${label} is the one that has the up arrow`)
})
test(`clicking the ${label} column header calls onChangeSort with ${columnID}`, function() {
const sortSpy = this.spy()
const wrapper = shallow(<CoursesList {...{
const wrapper = document.getElementById('fixtures')
ReactDOM.render(<CoursesList {...{
...coursesProps,
onChangeSort: sortSpy
}} />)
const header = wrapper.findWhere(n => n.text() === label).first().parents('Tooltip')
header.simulate('click')
ok(sortSpy.calledOnce)
ok(sortSpy.calledWith(columnID))
onChangeSort: this.mock().once().withArgs(columnID)
}} />, wrapper)
const header = Array.from(wrapper.querySelectorAll('[role=columnheader] button')).find(e => e.textContent.match(label))
header.click()
})
})

View File

@ -37,10 +37,6 @@ test('onUpdateFilters calls debouncedApplyFilters after updating state', () => {
roles={[{id: '1' }]}
queryParams={{}}
onUpdateQueryParams={function(){}}
addUserUrls={{
USER_LISTS_URL: '/',
ENROLL_USERS_URL: '/',
}}
/>
);
const instance = wrapper.instance();

View File

@ -462,7 +462,7 @@ define([
deepEqual(newState.apiState, {pendingCount: 0, error: undefined}, 'api is no longer in-flight');
deepEqual(newState.userValidationResult, runningState.userValidationResult, 'userValidationResult');
deepEqual(newState.usersToBeEnrolled, [], 'usersToBeEnrolled is emptied');
equal(newState.usersEnrolled, true, 'usersEnrolled');
equal(Boolean(newState.usersEnrolled), true, 'usersEnrolled');
});
test('ENROLL_USERS_ERROR', () => {
const state = _.cloneDeep(INITIAL_STATE);

View File

@ -133,12 +133,26 @@ describe "new account course search" do
get "/accounts/#{@account.id}"
fj('.courses-list [role=row] button:has([name="IconPlusLine"])').click
dialog = fj('.ui-dialog:visible')
dialog = fj('#add_people_modal:visible')
expect(dialog).to be_displayed
role_options = dialog.find_elements(:css, '#role_id option')
role_options = dialog.find_elements(:css, '#peoplesearch_select_role option')
expect(role_options.map{|r| r.text}).to match_array(["Student", "Observer", custom_name])
end
it "should load sections in new enrollment dialog" do
course = course_factory(:account => @account)
get "/accounts/#{@account.id}"
# doing this after the page loads to ensure that the frontend loads them dynamically
# when the "+ users" is clicked and not as part of the page load
sections = ('A'..'Z').map { |i| course.course_sections.create!(:name => "Test Section #{i}") }
fj('.courses-list [role=row] button:has([name="IconPlusLine"])').click # click the "+" to open addPeople
section_options = ffj('#add_people_modal:visible #peoplesearch_select_section option')
expect(section_options.map(&:text)).to eq(sections.map(&:name))
end
it "should create a new course from the 'Add a New Course' dialog" do
@account.enrollment_terms.create!(:name => "Test Enrollment Term")
subaccount = @account.sub_accounts.create!(name: "Test Sub Account")