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:
parent
136635caac
commit
5ceb01d44d
|
@ -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: {
|
||||
|
|
|
@ -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: []
|
||||
}
|
||||
|
|
|
@ -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'])
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
{onBack ? <Button id="addpeople_back" onClick={onBack}>{backLabel}</Button> : null}
|
||||
{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>
|
||||
|
||||
{onBack && (
|
||||
<Button id="addpeople_back" onClick={onBack}>
|
||||
{backLabel}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{onNext && (
|
||||
<Button id="addpeople_next" onClick={onNext} variant="primary" disabled={!readyForNext}>
|
||||
{nextLabel}
|
||||
</Button>
|
||||
)}
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
%>
|
||||
|
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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")
|
||||
|
|
Loading…
Reference in New Issue