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), priority_zones: localized_timezones(I18nTimeZone.us_zones),
timezones: localized_timezones(I18nTimeZone.all) timezones: localized_timezones(I18nTimeZone.all)
}, },
COURSE_ROLES: Role.course_role_data_for_account(@account, @current_user), 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)
}
}) })
js_bundle :account_course_user_search js_bundle :account_course_user_search
css_bundle :account_course_user_search css_bundle :account_course_user_search, :addpeople
@page_title = @account.name @page_title = @account.name
add_crumb '', '?' # the text for this will be set by javascript add_crumb '', '?' # the text for this will be set by javascript
js_env({ js_env({
ROOT_ACCOUNT_NAME: @domain_root_account.name, # used in AddPeopleApp modal
ACCOUNT_ID: @account.id, ACCOUNT_ID: @account.id,
ROOT_ACCOUNT_ID: @account.root_account.id, ROOT_ACCOUNT_ID: @account.root_account.id,
PERMISSIONS: { PERMISSIONS: {

View File

@ -16,163 +16,104 @@
* with this program. If not, see <http://www.gnu.org/licenses/>. * 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 React from 'react'
import PropTypes from 'prop-types' import {string, shape, arrayOf, func} from 'prop-types'
import $ from 'jquery'
import I18n from 'i18n!account_course_user_search' import I18n from 'i18n!account_course_user_search'
import axios from 'axios'
import CoursesListRow from './CoursesListRow' import CoursesListRow from './CoursesListRow'
import CoursesListHeader from './CoursesListHeader'
const { string, shape, arrayOf, func } = PropTypes export default function CoursesList(props) {
return (
class CoursesList extends React.Component { <div className="content-box" role="grid">
static propTypes = { <div role="row" className="grid-row border border-b pad-box-mini">
courses: arrayOf(shape(CoursesListRow.propTypes)).isRequired, <div className="col-xs-3">
addUserUrls: shape({ <div className="grid-row">
USER_LISTS_URL: string.isRequired, <div className="col-xs-2" />
ENROLL_USERS_URL: string.isRequired, <div className="col-xs-10" role="columnheader">
}).isRequired, <CoursesListHeader
onChangeSort: func.isRequired, {...props}
roles: arrayOf(shape({ id: string.isRequired })), id="course_name"
sort: string, label={I18n.t('Course')}
order: string, tipDesc={I18n.t('Click to sort by name ascending')}
}; tipAsc={I18n.t('Click to sort by name descending')}
/>
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>
</div> </div>
</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>
<div role="columnheader" className="col-xs-1">
<div className="courses-list" role="rowgroup"> <CoursesListHeader
{(courses || []).map((course) => { {...props}
const urlsForCourse = { id="sis_course_id"
USER_LISTS_URL: $.replaceTags(this.props.addUserUrls.USER_LISTS_URL, 'id', course.id), label={I18n.t('SIS ID')}
ENROLL_USERS_URL: $.replaceTags(this.props.addUserUrls.ENROLL_USERS_URL, 'id', course.id) tipDesc={I18n.t('Click to sort by SIS ID ascending')}
} tipAsc={I18n.t('Click to sort by SIS ID descending')}
/>
const courseSections = this.state.sections.filter(section => section.course_id === parseInt(course.id, 10)) </div>
<div role="columnheader" className="col-xs-1">
return ( <CoursesListHeader
<CoursesListRow {...props}
key={course.id} id="term"
courseModel={courses} label={I18n.t('Term')}
roles={this.props.roles} tipDesc={I18n.t('Click to sort by term ascending')}
urls={urlsForCourse} tipAsc={I18n.t('Click to sort by term descending')}
sections={courseSections} />
{...course} </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>
</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 $ from 'jquery'
import React from 'react' import React from 'react'
import {number, string, shape, arrayOf} from 'prop-types' import {number, string, shape, arrayOf} from 'prop-types'
import {Model} from 'Backbone'
import Button from 'instructure-ui/lib/components/Button' import Button from 'instructure-ui/lib/components/Button'
import Tooltip from 'instructure-ui/lib/components/Tooltip' import Tooltip from 'instructure-ui/lib/components/Tooltip'
import IconPlusLine from 'instructure-icons/lib/Line/IconPlusLine' import IconPlusLine from 'instructure-icons/lib/Line/IconPlusLine'
import IconSettingsLine from 'instructure-icons/lib/Line/IconSettingsLine' import IconSettingsLine from 'instructure-icons/lib/Line/IconSettingsLine'
import IconStatsLine from 'instructure-icons/lib/Line/IconStatsLine' import IconStatsLine from 'instructure-icons/lib/Line/IconStatsLine'
import _ from 'underscore' 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 I18n from 'i18n!account_course_user_search'
import UserLink from './UserLink' import UserLink from './UserLink'
import AddPeopleApp from '../add_people/add_people_app'
const uniqueTeachers = teachers => _.uniq(teachers, teacher => teacher.id) const uniqueTeachers = teachers => _.uniq(teachers, teacher => teacher.id)
@ -44,97 +38,70 @@ export default class CoursesListRow extends React.Component {
workflow_state: string.isRequired, workflow_state: string.isRequired,
total_students: number.isRequired, total_students: number.isRequired,
teachers: arrayOf(shape(UserLink.propTypes)).isRequired, teachers: arrayOf(shape(UserLink.propTypes)).isRequired,
sis_course_id: string.isRequired, sis_course_id: string,
subaccount_name: string.isRequired, subaccount_name: string.isRequired,
term: shape({name: string.isRequired}).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})), roles: arrayOf(shape({id: string.isRequired})),
sections: arrayOf(shape(UserLink.propTypes))
} }
static defaultProps = { static defaultProps = {
roles: [], roles: []
urls: {ENROLL_USERS_URL: '', USER_LISTS_URL: ''},
sections: []
} }
constructor(props) { constructor(props) {
super(props) super(props)
const teachers = uniqueTeachers(props.teachers) const teachers = uniqueTeachers(props.teachers)
this.state = { this.state = {
newlyEnrolledStudents: 0,
teachersToShow: _.compact([teachers[0], teachers[1]]) teachersToShow: _.compact([teachers[0], teachers[1]])
} }
} }
showMoreLink = () => { getSections = () =>
if (this.props.teachers.length > 2 && this.state.teachersToShow.length === 2) { this.promiseToGetSections ||
return ( (this.promiseToGetSections = $.get(`/api/v1/courses/${this.props.id}/sections?per_page=100`))
<Button variant="link" onClick={this.showMoreTeachers}>
{I18n.t('Show More')} handleNewEnrollments = newEnrollments => {
</Button> 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 = () => { showMoreTeachers = () => {
this.setState({teachersToShow: uniqueTeachers(this.props.teachers)}) 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() { render() {
const {id, name, workflow_state, sis_course_id, total_students, subaccount_name} = this.props const {id, name, workflow_state, sis_course_id, total_students, subaccount_name} = this.props
const url = `/courses/${id}` const url = `/courses/${id}`
@ -152,39 +119,41 @@ export default class CoursesListRow extends React.Component {
)} )}
</div> </div>
<div className="col-xs-10"> <div className="col-xs-10">
<div className="courseName"> <a href={url}>{name}</a>
<a href={url}>{name}</a>
</div>
</div> </div>
</div> </div>
</div> </div>
<div className="col-xs-1" role="gridcell"> <div className="col-xs-1" role="gridcell">
<div className="courseSIS">{sis_course_id}</div> {sis_course_id}
</div> </div>
<div className="col-xs-1" role="gridcell"> <div className="col-xs-1" role="gridcell">
<div className="courseSIS">{this.props.term.name}</div> {this.props.term.name}
</div> </div>
<div className="col-xs-2" role="gridcell"> <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} /> <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>
<div className="col-xs-2" role="gridcell"> <div className="col-xs-2" role="gridcell">
<div className="courseSubaccount">{subaccount_name}</div> {subaccount_name}
</div> </div>
<div className="col-xs-1" role="gridcell"> <div className="col-xs-1" role="gridcell">
<div className="totalStudents">{I18n.n(total_students)}</div> {I18n.n(total_students + this.state.newlyEnrolledStudents)}
</div> </div>
<div className="col-xs-2" role="gridcell"> <div className="col-xs-2" role="gridcell">
<div className="courses-user-list-actions"> <div className="courses-user-list-actions">
<Tooltip tip={I18n.t('Add Users to %{name}', {name})}> <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 /> <IconPlusLine />
</Button> </Button>
</Tooltip> </Tooltip>

View File

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

View File

@ -26,77 +26,77 @@ import { actions, actionTypes } from './actions'
import reducer from './reducer' import reducer from './reducer'
import AddPeople from './components/add_people' import AddPeople from './components/add_people'
class AddPeopleApp { export default class AddPeopleApp {
constructor (root, props) { constructor (root, props) {
this.root = root; // DOM node we render into this.root = root; // DOM node we render into
this.closer = this.close.bind(this); // close us this.closer = this.close.bind(this); // close us
this.onCloseCallback = props.onClose; // tell our parent this.onCloseCallback = props.onClose; // tell our parent
this.theme = props.theme || 'canvas'; this.theme = props.theme || 'canvas';
// natural sort the sections by name // natural sort the sections by name
let sections = props.sections || []; let sections = props.sections || [];
sections = sections.slice().sort(natcompare.byKey('name')); sections = sections.slice().sort(natcompare.byKey('name'));
// create the store with its initial state // create the store with its initial state
// some values are default, some come from props // some values are default, some come from props
this.store = createStore(reducer, { this.store = createStore(reducer, {
courseParams: { courseParams: {
courseId: props.courseId || 0, courseId: props.courseId || 0,
defaultInstitutionName: props.defaultInstitutionName || '', courseName: props.courseName || '',
roles: props.roles || [], defaultInstitutionName: props.defaultInstitutionName || '',
sections, roles: props.roles || [],
inviteUsersURL: props.inviteUsersURL sections,
}, inviteUsersURL: props.inviteUsersURL
inputParams: { },
searchType: defaultState.inputParams.searchType, inputParams: {
nameList: defaultState.inputParams.nameList, searchType: defaultState.inputParams.searchType,
role: props.roles.length ? props.roles[0].id : '', nameList: defaultState.inputParams.nameList,
section: sections.length ? sections[0].id : '', role: props.roles.length ? props.roles[0].id : '',
canReadSIS: props.canReadSIS section: sections.length ? sections[0].id : '',
}, canReadSIS: props.canReadSIS
apiState: defaultState.apiState, },
userValidationResult: defaultState.userValidationResult, apiState: defaultState.apiState,
usersToBeEnrolled: defaultState.usersToBeEnrolled userValidationResult: defaultState.userValidationResult,
}); usersToBeEnrolled: defaultState.usersToBeEnrolled
});
// when ConnectedApp is rendered, these state members are passed as props // when ConnectedApp is rendered, these state members are passed as props
function mapStateToProps (state) { function mapStateToProps (state) {
return { ...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);
} }
open () {
this.render(true);
} // when ConnectedApp is rendered, all the action dispatch functions are passed as props
close () { const mapDispatchToProps = dispatch => bindActionCreators(actions, dispatch)
this.render(false);
if (typeof this.onCloseCallback === 'function') { // connect our top-level component to redux
this.onCloseCallback(); this.ConnectedApp = connect(mapStateToProps, mapDispatchToProps)(AddPeople);
} }
} open () {
// used by the roster page to decide if it has to requry for the course's this.render(true);
// enrollees }
usersHaveBeenEnrolled () { close () {
return this.store.getState().usersEnrolled; this.render(false);
} if (typeof this.onCloseCallback === 'function') {
unmount () { this.onCloseCallback();
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
)
} }
} }
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 I18n from 'i18n!roster'
import React from 'react' 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 Modal, {ModalHeader, ModalBody, ModalFooter} from 'instructure-ui/lib/components/Modal'
import Heading from 'instructure-ui/lib/components/Heading' import Heading from 'instructure-ui/lib/components/Heading'
import Button from 'instructure-ui/lib/components/Button' import Button from 'instructure-ui/lib/components/Button'
import Spinner from 'instructure-ui/lib/components/Spinner' import Spinner from 'instructure-ui/lib/components/Spinner'
import ScreenReaderContent from 'instructure-ui/lib/components/ScreenReaderContent' 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 PeopleSearch from './people_search'
import PeopleReadyList from './people_ready_list' import PeopleReadyList from './people_ready_list'
import PeopleValidationIssues from './people_validation_issues' import PeopleValidationIssues from './people_validation_issues'
import APIError from './api_error' import APIError from './api_error'
const PEOPLESEARCH = 'peoplesearch'; const PEOPLESEARCH = 'peoplesearch'
const PEOPLEREADYLIST = 'peoplereadylist'; const PEOPLEREADYLIST = 'peoplereadylist'
const PEOPLEVALIDATIONISSUES = 'peoplevalidationissues'; const PEOPLEVALIDATIONISSUES = 'peoplevalidationissues'
const RESULTPENDING = 'resultpending'; const RESULTPENDING = 'resultpending'
const APIERROR = 'apierror'; const APIERROR = 'apierror'
// @param props: the component's properties to consider const isReadyToCreate = candidate =>
// @returns true if our user has dealt with all the duplicates and missing !!(candidate.createNew && candidate.newUserInfo && candidate.newUserInfo.email) // newUserInfo.name is now optional
// search results
function arePeopleValidationIssuesResolved (props) { // @param props: the component's properties to consider
function isReadyToCreate (candidate) { // @returns true if our user has dealt with all the duplicates and missing
return !!(candidate.createNew && candidate.newUserInfo // search results
&& candidate.newUserInfo.email); // newUserInfo.name is now optional 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) => { // event handlers ---------------------
const dupe = props.userValidationResult.duplicates[address]; // search input changes
return !(dupe.selectedUserId >= 0 || dupe.skip || isReadyToCreate(dupe)) 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 if (typeof this.props.onClose === 'function') this.props.onClose()
this.props.reset()
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;
} }
class AddPeople extends React.Component { // go back to a previous panel in the modal
// TODO: deal with defaut props after the warmfix to keep this change small // @param pagename: name of the panel to return to
/* eslint-disable react/require-default-props */ // @param stateResets: arrayOf(string): which of the state sub-sections
static propTypes = { // should get reset to default values.
isOpen: PropTypes.bool, // undefined implies all
validateUsers: PropTypes.func.isRequired, goBack(pagename, stateResets) {
enrollUsers: PropTypes.func.isRequired, this.props.reset(stateResets)
onClose: PropTypes.func, this.setState({currentPage: pagename, focusToTop: true})
// these props are generated from store state }
courseParams: PropTypes.shape(courseParamsShape), // different panels go back slightly differently
apiState: PropTypes.shape(apiStateShape), apiErrorOnBack = () => {
inputParams: PropTypes.shape(inputParamsShape), this.goBack(PEOPLESEARCH, [])
userValidationResult: PropTypes.shape(validateResultShape), }
usersToBeEnrolled: PropTypes.arrayOf(PropTypes.shape(personReadyToEnrollShape)), peopleReadyOnBack = () => {
// these are props generated from actions this.goBack(PEOPLESEARCH, undefined)
setInputParams: PropTypes.func, }
chooseDuplicate: PropTypes.func, peopleValidationIssuesOnBack = () => {
enqueueNewForDuplicate: PropTypes.func, this.goBack(PEOPLESEARCH, ['userValidationResult'])
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>
);
}
} }
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' import '../store'
export default handleActions({ export default handleActions({
[actionTypes.ENROLL_USERS_SUCCESS]: (/* state, action */) => true, [actionTypes.ENROLL_USERS_SUCCESS]: (state, action) => action.payload,
[actionTypes.RESET]: (/* state, action */) => false [actionTypes.RESET]: (/* state, action */) => false
}, 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; 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') translated_title = @context.is_a?(Course) ? t('Course Roster') : t('Group Roster')
content_for :page_title, join_title(translated_title, @context.name) 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) is_teacher = can_do(@context, @current_user, :manage_students)
%> %>

View File

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

View File

@ -37,10 +37,6 @@ test('onUpdateFilters calls debouncedApplyFilters after updating state', () => {
roles={[{id: '1' }]} roles={[{id: '1' }]}
queryParams={{}} queryParams={{}}
onUpdateQueryParams={function(){}} onUpdateQueryParams={function(){}}
addUserUrls={{
USER_LISTS_URL: '/',
ENROLL_USERS_URL: '/',
}}
/> />
); );
const instance = wrapper.instance(); 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.apiState, {pendingCount: 0, error: undefined}, 'api is no longer in-flight');
deepEqual(newState.userValidationResult, runningState.userValidationResult, 'userValidationResult'); deepEqual(newState.userValidationResult, runningState.userValidationResult, 'userValidationResult');
deepEqual(newState.usersToBeEnrolled, [], 'usersToBeEnrolled is emptied'); deepEqual(newState.usersToBeEnrolled, [], 'usersToBeEnrolled is emptied');
equal(newState.usersEnrolled, true, 'usersEnrolled'); equal(Boolean(newState.usersEnrolled), true, 'usersEnrolled');
}); });
test('ENROLL_USERS_ERROR', () => { test('ENROLL_USERS_ERROR', () => {
const state = _.cloneDeep(INITIAL_STATE); const state = _.cloneDeep(INITIAL_STATE);

View File

@ -133,12 +133,26 @@ describe "new account course search" do
get "/accounts/#{@account.id}" get "/accounts/#{@account.id}"
fj('.courses-list [role=row] button:has([name="IconPlusLine"])').click 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 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]) expect(role_options.map{|r| r.text}).to match_array(["Student", "Observer", custom_name])
end 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 it "should create a new course from the 'Add a New Course' dialog" do
@account.enrollment_terms.create!(:name => "Test Enrollment Term") @account.enrollment_terms.create!(:name => "Test Enrollment Term")
subaccount = @account.sub_accounts.create!(name: "Test Sub Account") subaccount = @account.sub_accounts.create!(name: "Test Sub Account")