Clean up account course/user search pages

Closes: CORE-334 CORE-211

* takes out tabs from page
* changes “Courses & People” tab back to separate
  “Courses” and “People” tabs. They are still a single page app
  between those 2 tabs
* changes urls back to accounts/x for “Courses” tab and
  accounts/x/users for “People” tab (like they were previously)

Test plan:
* turn on MRA, Make sure the “suspend/unsuspend account”
  button on account settings page still works
* make sure the “find a user anywhere for siteAdmin/consortium root
  accounts” still works
* check to make sure going to the “People” and “courses” tab on the
  Left of the account nav works correctly
* verify that you can copy/paste the url showing for a given
  search result and it will come up with the same search results in
  another tab
* general regression on the account course & user pages.

Change-Id: Idfade57b2abd189a84ba5372d8e4aad1fb3ea49b
Reviewed-on: https://gerrit.instructure.com/128822
Tested-by: Jenkins
Reviewed-by: Clay Diffrient <cdiffrient@instructure.com>
QA-Review: Jeremy Putnam <jeremyp@instructure.com>
Product-Review: Ryan Shaw <ryan@instructure.com>
This commit is contained in:
Ryan Shaw 2017-10-06 10:05:02 -06:00
parent c004ff0367
commit 3c30c9b6df
36 changed files with 877 additions and 1023 deletions

View File

@ -195,43 +195,6 @@ class AccountsController < ApplicationController
end end
end end
def course_user_search
return unless authorized_action(@account, @current_user, :read)
can_read_course_list = !@account.site_admin? && @account.grants_right?(@current_user, session, :read_course_list)
can_read_roster = @account.grants_right?(@current_user, session, :read_roster)
can_manage_account = @account.grants_right?(@current_user, session, :manage_account_settings)
unless can_read_course_list || can_read_roster
return render_unauthorized_action
end
@permissions = {
theme_editor: can_manage_account && @account.branding_allowed?,
can_read_course_list: can_read_course_list,
can_read_roster: can_read_roster,
can_create_courses: @account.grants_right?(@current_user, session, :manage_courses),
can_create_users: @account.root_account? && @account.grants_right?(@current_user, session, :manage_user_logins),
analytics: @account.service_enabled?(:analytics),
can_masquerade: @account.grants_right?(@current_user, session, :become_user),
can_message_users: @account.grants_right?(@current_user, session, :send_messages),
can_edit_users: @account.grants_any_right?(@current_user, session, :manage_students, :manage_user_logins)
}
js_env({
TIMEZONES: {
priority_zones: localized_timezones(I18nTimeZone.us_zones),
timezones: localized_timezones(I18nTimeZone.all)
},
BASE_PATH: request.env['PATH_INFO'].sub(/\/search.*/, '') + '/search',
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)
}
})
render template: "accounts/course_user_search"
end
# @API Get the sub-accounts of an account # @API Get the sub-accounts of an account
# #
# List accounts that are sub-accounts of the given account. # List accounts that are sub-accounts of the given account.
@ -1169,16 +1132,6 @@ class AccountsController < ApplicationController
rce_js_env(:basic) rce_js_env(:basic)
end end
def localized_timezones(timezones)
timezones.map do |timezone|
{
name: timezone.name,
localized_name: timezone.to_s
}
end
end
private :localized_timezones
private private
def ensure_sis_max_name_length_value!(account_settings) def ensure_sis_max_name_length_value!(account_settings)

View File

@ -2279,4 +2279,57 @@ class ApplicationController < ActionController::Base
def teardown_live_events_context def teardown_live_events_context
LiveEvents.clear_context! LiveEvents.clear_context!
end end
# TODO: this belongs in AccountsController but while :course_user_search is still behind a feature flag we
# have to let UsersController::index own the /accounts/x/users route so it responds as it used to if the
# feature isn't enabled but `return course_user_search` if the feature is enabled. you can't `return` an
# action from another controller but you can from a controller you inherit from. Hence why this can be
# here in ApplicationController but not AccountsController for now. Once we remove the feature flag,
# we should move this back to AccountsController and just change conf/routes.rb to let
# AccountsController::users own /accounts/x/users instead UsersController::index
def course_user_search
return unless authorized_action(@account, @current_user, :read)
can_read_course_list = @account.grants_right?(@current_user, session, :read_course_list)
can_read_roster = @account.grants_right?(@current_user, session, :read_roster)
can_manage_account = @account.grants_right?(@current_user, session, :manage_account_settings)
unless can_read_course_list || can_read_roster
return render_unauthorized_action
end
def localized_timezones(zones)
zones.map { |tz| {name: tz.name, localized_name: tz.to_s} }
end
js_env({
TIMEZONES: {
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)
}
})
js_bundle :account_course_user_search
css_bundle :account_course_user_search
@page_title = @account.name
add_crumb '', '?' # the text for this will be set by javascript
js_env({
ACCOUNT_ID: @account.id,
PERMISSIONS: {
can_read_course_list: can_read_course_list,
can_read_roster: can_read_roster,
can_create_courses: @account.grants_right?(@current_user, session, :manage_courses),
can_create_users: @account.root_account? && @account.grants_right?(@current_user, session, :manage_user_logins),
analytics: @account.service_enabled?(:analytics),
can_masquerade: @account.grants_right?(@current_user, session, :become_user),
can_message_users: @account.grants_right?(@current_user, session, :send_messages),
can_edit_users: @account.grants_any_right?(@current_user, session, :manage_students, :manage_user_logins)
}
})
render html: '', layout: true
end
end end

View File

@ -390,6 +390,11 @@ class UsersController < ApplicationController
# @returns [User] # @returns [User]
def index def index
get_context get_context
if !api_request? && @context.feature_enabled?(:course_user_search)
@account ||= @context
return course_user_search
end
if authorized_action(@context, @current_user, :read_roster) if authorized_action(@context, @current_user, :read_roster)
@root_account = @context.root_account @root_account = @context.root_account
@query = (params[:user] && params[:user][:name]) || params[:term] @query = (params[:user] && params[:user][:name]) || params[:term]

View File

@ -150,7 +150,7 @@ class CoursesList extends React.Component {
</div> </div>
<div className="courses-list" role="rowgroup"> <div className="courses-list" role="rowgroup">
{courses.map((course) => { {(courses || []).map((course) => {
const urlsForCourse = { const urlsForCourse = {
USER_LISTS_URL: $.replaceTags(this.props.addUserUrls.USER_LISTS_URL, 'id', course.id), 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) ENROLL_USERS_URL: $.replaceTags(this.props.addUserUrls.ENROLL_USERS_URL, 'id', course.id)

View File

@ -17,19 +17,27 @@
*/ */
import React from 'react' import React from 'react'
import PropTypes from 'prop-types' import {shape, arrayOf, string, func} from 'prop-types'
import { debounce } from 'underscore' import {debounce} from 'underscore'
import I18n from 'i18n!account_course_user_search' import I18n from 'i18n!account_course_user_search'
import CoursesStore from './CoursesStore' import CoursesStore from './CoursesStore'
import TermsStore from './TermsStore' import TermsStore from './TermsStore'
import AccountsTreeStore from './AccountsTreeStore' import AccountsTreeStore from './AccountsTreeStore'
import CoursesList from './CoursesList' import CoursesList from './CoursesList'
import CoursesToolbar from './CoursesToolbar' import CoursesToolbar from './CoursesToolbar'
import renderSearchMessage from './renderSearchMessage' import SearchMessage from './SearchMessage'
const MIN_SEARCH_LENGTH = 3 const MIN_SEARCH_LENGTH = 3
const stores = [CoursesStore, TermsStore, AccountsTreeStore] const stores = [CoursesStore, TermsStore, AccountsTreeStore]
const { shape, arrayOf, string } = PropTypes
const defaultFilters = {
enrollment_term_id: '',
search_term: '',
with_students: false,
sort: 'sis_course_id',
order: 'asc',
search_by: 'course'
}
class CoursesPane extends React.Component { class CoursesPane extends React.Component {
static propTypes = { static propTypes = {
@ -38,26 +46,22 @@ class CoursesPane extends React.Component {
USER_LISTS_URL: string.isRequired, USER_LISTS_URL: string.isRequired,
ENROLL_USERS_URL: string.isRequired, ENROLL_USERS_URL: string.isRequired,
}).isRequired, }).isRequired,
accountId: string.isRequired, queryParams: shape().isRequired,
onUpdateQueryParams: func.isRequired,
accountId: string.isRequired
} }
constructor () { constructor () {
super() super()
const filters = {
enrollment_term_id: '',
search_term: '',
with_students: false,
sort: 'sis_course_id',
order: 'asc',
search_by: 'course',
}
this.state = { this.state = {
filters, filters: defaultFilters,
draftFilters: filters, draftFilters: defaultFilters,
errors: {}, errors: {},
previousCourses: {data: []}, previousCourses: {
data: [],
loading: true
}
} }
// Doing this here because the class property version didn't work :( // Doing this here because the class property version didn't work :(
@ -66,6 +70,8 @@ class CoursesPane extends React.Component {
componentWillMount () { componentWillMount () {
stores.forEach(s => s.addChangeListener(this.refresh)) stores.forEach(s => s.addChangeListener(this.refresh))
const filters = Object.assign({}, defaultFilters, this.props.queryParams)
this.setState({filters, draftFilters: filters})
} }
componentDidMount () { componentDidMount () {
@ -78,7 +84,13 @@ class CoursesPane extends React.Component {
stores.forEach(s => s.removeChangeListener(this.refresh)) stores.forEach(s => s.removeChangeListener(this.refresh))
} }
componentWillReceiveProps(nextProps) {
const filters = Object.assign({}, defaultFilters, nextProps.queryParams)
this.setState({filters, draftFilters: filters})
}
fetchCourses = () => { fetchCourses = () => {
this.updateQueryString()
CoursesStore.load(this.state.filters) CoursesStore.load(this.state.filters)
} }
@ -121,6 +133,17 @@ class CoursesPane extends React.Component {
this.forceUpdate() this.forceUpdate()
} }
updateQueryString = () => {
const differences = Object.keys(this.state.filters).reduce((memo, key) => {
const value = this.state.filters[key]
if (value !== defaultFilters[key]) {
return {...memo, [key]: value}
}
return memo
}, {})
this.props.onUpdateQueryParams(differences)
}
render () { render () {
const { filters, draftFilters, errors } = this.state const { filters, draftFilters, errors } = this.state
let courses = CoursesStore.get(filters) let courses = CoursesStore.get(filters)
@ -139,8 +162,8 @@ class CoursesPane extends React.Component {
terms={terms && terms.data} terms={terms && terms.data}
accounts={accounts} accounts={accounts}
isLoading={isLoading} isLoading={isLoading}
{...draftFilters}
errors={errors} errors={errors}
draftFilters={draftFilters}
/> />
<CoursesList <CoursesList
@ -153,7 +176,11 @@ class CoursesPane extends React.Component {
order={filters.order} order={filters.order}
/> />
{renderSearchMessage(courses, this.fetchMoreCourses, I18n.t('No courses found'))} <SearchMessage
collection={courses}
loadMore={this.fetchMoreCourses}
noneFoundMessage={I18n.t('No courses found')}
/>
</div> </div>
) )
} }

View File

@ -17,8 +17,9 @@
*/ */
import React from 'react' import React from 'react'
import PropTypes from 'prop-types' import { string, bool, func, arrayOf, shape, oneOf } from 'prop-types'
import I18n from 'i18n!account_course_user_search' import I18n from 'i18n!account_course_user_search'
import preventDefault from 'compiled/fn/preventDefault'
import TermsStore from './TermsStore' import TermsStore from './TermsStore'
import AccountsTreeStore from './AccountsTreeStore' import AccountsTreeStore from './AccountsTreeStore'
import NewCourseModal from './NewCourseModal' import NewCourseModal from './NewCourseModal'
@ -26,17 +27,35 @@ import IcInput from './IcInput'
import IcSelect from './IcSelect' import IcSelect from './IcSelect'
import IcCheckbox from './IcCheckbox' import IcCheckbox from './IcCheckbox'
const { string, bool, func, arrayOf, shape } = PropTypes const TermOpts = ({terms}) => {
return terms ? (
<optgroup label={I18n.t('Show courses from')}>
<option key="all" value="">
{I18n.t('All Terms')}
</option>
{terms.map(term =>
<option key={term.id} value={term.id}>
{term.name}
</option>
)}
</optgroup>
) : (
<option value="">{I18n.t('Loading...')}</option>
)
}
TermOpts.propTypes = { terms: arrayOf(TermsStore.PropType) }
class CoursesToolbar extends React.Component { export default class CoursesToolbar extends React.Component {
static propTypes = { static propTypes = {
onUpdateFilters: func.isRequired, onUpdateFilters: func.isRequired,
onApplyFilters: func.isRequired, onApplyFilters: func.isRequired,
isLoading: bool, isLoading: bool.isRequired,
with_students: bool.isRequired, draftFilters: shape({
search_term: string, with_students: bool.isRequired,
enrollment_term_id: string, search_by: oneOf(['course', 'teacher']).isRequired,
sortColumn: string, search_term: string.isRequired,
enrollment_term_id: string.isRequired,
}).isRequired,
errors: shape({ search_term: string }).isRequired, errors: shape({ search_term: string }).isRequired,
terms: arrayOf(TermsStore.PropType), terms: arrayOf(TermsStore.PropType),
accounts: arrayOf(AccountsTreeStore.PropType), accounts: arrayOf(AccountsTreeStore.PropType),
@ -45,42 +64,15 @@ class CoursesToolbar extends React.Component {
static defaultProps = { static defaultProps = {
terms: null, terms: null,
accounts: [], accounts: [],
search_term: '',
enrollment_term_id: null,
isLoading: false, isLoading: false,
sortColumn: ''
}
applyFilters = (e) => {
e.preventDefault()
this.props.onApplyFilters()
} }
addCourse = () => { addCourse = () => {
this.addCourseModal.openModal() this.addCourseModal.openModal()
} }
renderTerms () {
const { terms } = this.props
if (terms) {
return [
<option key="all" value="">
{I18n.t('All Terms')}
</option>
].concat(terms.map(term => (
<option key={term.id} value={term.id}>
{term.name}
</option>
)))
}
return <option value="">{I18n.t('Loading...')}</option>
}
render () { render () {
const { terms, accounts, onUpdateFilters, isLoading, errors, ...props } = this.props const { terms, accounts, onUpdateFilters, isLoading, errors, draftFilters} = this.props
const addCourseButton = window.ENV.PERMISSIONS.can_create_courses ? const addCourseButton = window.ENV.PERMISSIONS.can_create_courses ?
(<div> (<div>
@ -96,31 +88,36 @@ class CoursesToolbar extends React.Component {
<form <form
className="course_search_bar" className="course_search_bar"
style={{opacity: isLoading ? 0.5 : 1}} style={{opacity: isLoading ? 0.5 : 1}}
onSubmit={this.applyFilters} onSubmit={preventDefault(this.props.onApplyFilters)}
disabled={isLoading} disabled={isLoading}
> >
<div className="ic-Form-action-box courses-list-search-bar-layout"> <div className="ic-Form-action-box courses-list-search-bar-layout">
<div className="ic-Form-action-box__Form"> <div className="ic-Form-action-box__Form">
<IcSelect <IcSelect
value={props.enrollment_term_id} value={draftFilters.enrollment_term_id}
onChange={e => onUpdateFilters({enrollment_term_id: e.target.value})} onChange={e => onUpdateFilters({enrollment_term_id: e.target.value})}
> >
{this.renderTerms()} <TermOpts terms={terms} />
</IcSelect> </IcSelect>
<IcSelect <IcSelect
value={props.search_by} value={draftFilters.search_by}
onChange={e => onUpdateFilters({search_by: e.target.value})} onChange={e => onUpdateFilters({search_by: e.target.value})}
> >
<option key="course" value="course"> <optgroup label={I18n.t('Search By')}>
{I18n.t('Course')} <option key="course" value="course">
</option> {I18n.t('Course')}
<option key="teacher" value="teacher"> </option>
{I18n.t('Teacher')} <option key="teacher" value="teacher">
</option> {I18n.t('Teacher')}
</option>
</optgroup>
</IcSelect> </IcSelect>
<IcInput <IcInput
value={props.search_term} value={draftFilters.search_term}
placeholder={I18n.t('Search courses...')} placeholder={draftFilters.search_by === 'teacher' ?
I18n.t('Search courses by teacher...') :
I18n.t('Search courses...')
}
onChange={e => onUpdateFilters({search_term: e.target.value})} onChange={e => onUpdateFilters({search_term: e.target.value})}
error={errors.search_term} error={errors.search_term}
type="search" type="search"
@ -131,7 +128,7 @@ class CoursesToolbar extends React.Component {
</div> </div>
</div> </div>
<IcCheckbox <IcCheckbox
checked={props.with_students} checked={draftFilters.with_students}
onChange={e => onUpdateFilters({with_students: e.target.checked})} onChange={e => onUpdateFilters({with_students: e.target.checked})}
label={I18n.t('Hide courses without enrollments')} label={I18n.t('Hide courses without enrollments')}
/> />
@ -146,4 +143,3 @@ class CoursesToolbar extends React.Component {
} }
} }
export default CoursesToolbar

View File

@ -0,0 +1,57 @@
/*
* Copyright (C) 2015 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from 'react'
import { array, func, string } from 'prop-types'
import I18n from 'i18n!account_course_user_search'
export default function SearchMessage({ collection, loadMore, noneFoundMessage }) {
if (!collection || collection.loading) {
return <div className="text-center pad-box">{I18n.t('Loading...')}</div>
} else if (collection.error) {
return (
<div className="text-center pad-box">
<div className="alert alert-error">
{I18n.t('There was an error with your query; please try a different search')}
</div>
</div>
)
} else if (!collection.data.length) {
return (
<div className="text-center pad-box">
<div className="alert alert-info">{noneFoundMessage}</div>
</div>
)
} else if (collection.next) {
return (
<div className="text-center pad-box">
<button className="Button--link load_more" onClick={loadMore}>
<i className="icon-refresh" /> {I18n.t('Load more...')}
</button>
</div>
)
} else {
return <noscript />
}
}
SearchMessage.propTypes = {
collection: array.isRequired,
loadMore: func.isRequired,
noneFoundMessage: string.isRequired
}

View File

@ -17,30 +17,24 @@
*/ */
import React from 'react' import React from 'react'
import PropTypes from 'prop-types' import {string} from 'prop-types'
import I18n from 'i18n!account_course_user_search'
var UserLink = React.createClass({ export default function UserLink ({ id, display_name, avatar_image_url }) {
propTypes: { const url = `/users/${id}`
id: PropTypes.string.isRequired, return (
display_name: PropTypes.string.isRequired, <div className="ellipsis">
avatar_image_url: PropTypes.string {!!avatar_image_url &&
}, <span className="ic-avatar UserLink__Avatar">
<img src={avatar_image_url} alt='' />
</span>
}
<a href={url} className="user_link">{display_name}</a>
</div>
)
}
render() { UserLink.propTypes = {
var { id, display_name, avatar_image_url } = this.props; id: string.isRequired,
var url = `/users/${id}`; display_name: string.isRequired,
return ( avatar_image_url: string
<div className="ellipsis"> }
{!!avatar_image_url &&
<span className="ic-avatar UserLink__Avatar">
<img src={avatar_image_url} alt={`User avatar for ${display_name}`} />
</span>
}
<a href={url} className="user_link">{display_name}</a>
</div>
);
}
});
export default UserLink

View File

@ -17,25 +17,30 @@
*/ */
import React from 'react' import React from 'react'
import PropTypes from 'prop-types' import {shape, func, arrayOf, string } from 'prop-types'
import I18n from 'i18n!account_course_user_search' import I18n from 'i18n!account_course_user_search'
import _ from 'underscore' import _ from 'underscore'
import UsersStore from './UsersStore' import UsersStore from './UsersStore'
import UsersList from './UsersList' import UsersList from './UsersList'
import UsersToolbar from './UsersToolbar' import UsersToolbar from './UsersToolbar'
import renderSearchMessage from './renderSearchMessage' import SearchMessage from './SearchMessage'
import UserActions from './actions/UserActions' import UserActions from './actions/UserActions'
const MIN_SEARCH_LENGTH = 3; const MIN_SEARCH_LENGTH = 3;
class UsersPane extends React.Component { export default class UsersPane extends React.Component {
static propTypes = { static propTypes = {
store: PropTypes.shape({ store: shape({
getState: PropTypes.func.isRequired, getState: func.isRequired,
dispatch: PropTypes.func.isRequired, dispatch: func.isRequired,
subscribe: PropTypes.func.isRequired, subscribe: func.isRequired,
}).isRequired, }).isRequired,
roles: PropTypes.arrayOf(PropTypes.string).isRequired, roles: arrayOf(string).isRequired,
onUpdateQueryParams: func.isRequired,
queryParams: shape({
search_term: string,
role_filter_id: string
}).isRequired
}; };
constructor (props) { constructor (props) {
@ -48,7 +53,12 @@ class UsersPane extends React.Component {
componentDidMount = () => { componentDidMount = () => {
this.unsubscribe = this.props.store.subscribe(this.handleStateChange); this.unsubscribe = this.props.store.subscribe(this.handleStateChange);
this.props.store.dispatch(UserActions.apiGetUsers());
// make page reflect what the querystring params asked for
const {search_term, role_filter_id} = {...UsersToolbar.defaultProps, ...this.props.queryParams}
this.props.store.dispatch(UserActions.updateSearchFilter({search_term, role_filter_id}))
this.props.store.dispatch(UserActions.applySearchFilter(MIN_SEARCH_LENGTH))
} }
componentWillUnmount = () => { componentWillUnmount = () => {
@ -64,12 +74,16 @@ class UsersPane extends React.Component {
} }
handleApplyingSearchFilter = () => { handleApplyingSearchFilter = () => {
this.props.store.dispatch(UserActions.applySearchFilter(MIN_SEARCH_LENGTH)); this.props.store.dispatch(UserActions.applySearchFilter(MIN_SEARCH_LENGTH))
this.updateQueryString()
} }
debouncedDispatchApplySearchFilter = _.debounce(() => { updateQueryString = () => {
this.props.store.dispatch(UserActions.applySearchFilter(MIN_SEARCH_LENGTH)); const searchFilter = this.props.store.getState().userList.searchFilter
}, 250); this.props.onUpdateQueryParams(searchFilter)
}
debouncedDispatchApplySearchFilter = _.debounce(this.handleApplyingSearchFilter, 250)
handleUpdateSearchFilter = (searchFilter) => { handleUpdateSearchFilter = (searchFilter) => {
this.props.store.dispatch(UserActions.updateSearchFilter(searchFilter)); this.props.store.dispatch(UserActions.updateSearchFilter(searchFilter));
@ -97,14 +111,13 @@ class UsersPane extends React.Component {
} }
handleAddNewUserFormErrors = (errors) => { handleAddNewUserFormErrors = (errors) => {
for (const key in errors) { Object.keys(errors).forEach(key => {
this.props.store.dispatch(UserActions.addError({[key]: errors[key]})); this.props.store.dispatch(UserActions.addError({[key]: errors[key]}))
} })
} }
render () { render () {
const {next, timezones, accountId, users, isLoading, errors, searchFilter} = this.state.userList; const {next, timezones, accountId, users, isLoading, errors, searchFilter} = this.state.userList
const collection = {data: users, loading: isLoading, next};
return ( return (
<div> <div>
{<UsersToolbar {<UsersToolbar
@ -139,10 +152,12 @@ class UsersPane extends React.Component {
/> />
} }
{renderSearchMessage(collection, this.handleGetMoreUsers, I18n.t('No users found'))} <SearchMessage
collection={{data: users, loading: isLoading, next}}
loadMore={this.handleGetMoreUsers}
noneFoundMessage={I18n.t('No users found')}
/>
</div> </div>
); )
} }
} }
export default UsersPane

View File

@ -16,17 +16,14 @@
* with this program. If not, see <http://www.gnu.org/licenses/>. * with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import I18n from 'i18n!user_actions' export default {
selectTab({selected, queryParams}) {
const TabActions = { return {
selectTab(tabIndex) { type: 'SELECT_TAB',
return { payload: {
type: 'SELECT_TAB', selected,
payload: { queryParams
tabIndex: tabIndex }
}
};
} }
}; }
}
export default TabActions

View File

@ -18,136 +18,120 @@
import $ from 'jquery' import $ from 'jquery'
import UsersStore from 'jsx/account_course_user_search/UsersStore' import UsersStore from 'jsx/account_course_user_search/UsersStore'
import _ from 'underscore'
import I18n from 'i18n!user_actions' import I18n from 'i18n!user_actions'
const UserActions = { export default {
apiCreateUser (accountId, attributes) { apiCreateUser(accountId, attributes) {
return (dispatch, getState) => { return (dispatch, _getState) => {
UsersStore.create(attributes).then((response, _, xhr) => {
UsersStore.create(attributes).then((response, _, xhr) => { dispatch(this.addToUsers([response], xhr))
dispatch(this.addToUsers([response], xhr)); })
});
};
},
addError (error) {
return {
type: 'ADD_ERROR',
error
};
},
apiGetUsers () {
return (dispatch, getState) => {
let users = getState().userList.users;
if (_.isEmpty(users)) {
UsersStore.load({search_term: ''}).then((response, _, xhr) => {
dispatch(this.gotUserList(response, xhr));
});
} else {
dispatch(this.gotUserList(users));
}
};
},
apiUpdateUser(attributes, userId) {
return (dispatch, getState) => {
let url = `/api/v1/users/${userId}`;
$.ajaxJSON(url, "PUT", {user: attributes}).then((response) => {
dispatch(this.gotUserUpdate(response));
});
};
},
gotUserList (users, xhr) {
return {
type: 'GOT_USERS',
payload: {
users: users,
xhr: xhr
}
};
},
gotUserUpdate (user) {
return {
type: 'GOT_USER_UPDATE',
payload: user
};
},
openEditUserDialog (user) {
return {
type: 'OPEN_EDIT_USER_DIALOG',
payload: user
};
},
closeEditUserDialog (user) {
return {
type: 'CLOSE_EDIT_USER_DIALOG',
payload: user
};
},
updateSearchFilter (filter) {
return {
type: 'UPDATE_SEARCH_FILTER',
payload: filter
};
},
displaySearchTermTooShortError (minSearchLength) {
return {
type: 'SEARCH_TERM_TOO_SHORT',
errors: {
termTooShort: I18n.t("Search term must be at least %{num} characters", {num: minSearchLength})
}
};
},
loadingUsers () {
return {
type: 'LOADING_USERS'
};
},
addToUsers (users, xhr) {
return {
type: 'ADD_TO_USERS',
payload: {
users: users,
xhr: xhr
}
};
},
getMoreUsers (store = UsersStore) {
return (dispatch, getState) => {
let searchFilter = getState().userList.searchFilter;
dispatch(this.loadingUsers());
store.loadMore(searchFilter).then((response, _, xhr) => {
dispatch(this.addToUsers(response, xhr));
});
};
},
applySearchFilter (minSearchLength, store = UsersStore) {
return (dispatch, getState) => {
let searchFilter = getState().userList.searchFilter;
if (searchFilter.search_term.length >= minSearchLength || searchFilter.search_term === "") {
dispatch(this.loadingUsers());
store.load(searchFilter).then((response, _, xhr) => {
dispatch(this.gotUserList(response, xhr));
});
} else {
dispatch(this.displaySearchTermTooShortError(minSearchLength));
}
};
} }
}; },
export default UserActions addError(error) {
return {
type: 'ADD_ERROR',
error
}
},
apiUpdateUser(attributes, userId) {
return (dispatch, _getState) => {
const url = `/api/v1/users/${userId}`
$.ajaxJSON(url, 'PUT', {user: attributes}).then(response => {
dispatch(this.gotUserUpdate(response))
})
}
},
gotUserList(users, xhr) {
return {
type: 'GOT_USERS',
payload: {
users,
xhr
}
}
},
gotUserUpdate(user) {
return {
type: 'GOT_USER_UPDATE',
payload: user
}
},
openEditUserDialog(user) {
return {
type: 'OPEN_EDIT_USER_DIALOG',
payload: user
}
},
closeEditUserDialog(user) {
return {
type: 'CLOSE_EDIT_USER_DIALOG',
payload: user
}
},
updateSearchFilter(filter) {
return {
type: 'UPDATE_SEARCH_FILTER',
payload: filter
}
},
displaySearchTermTooShortError(minSearchLength) {
return {
type: 'SEARCH_TERM_TOO_SHORT',
errors: {
termTooShort: I18n.t('Search term must be at least %{num} characters', {
num: minSearchLength
})
}
}
},
loadingUsers() {
return {
type: 'LOADING_USERS'
}
},
addToUsers(users, xhr) {
return {
type: 'ADD_TO_USERS',
payload: {
users,
xhr
}
}
},
getMoreUsers(store = UsersStore) {
return (dispatch, getState) => {
const searchFilter = getState().userList.searchFilter
dispatch(this.loadingUsers())
store.loadMore(searchFilter).then((response, _, xhr) => {
dispatch(this.addToUsers(response, xhr))
})
}
},
applySearchFilter(minSearchLength, store = UsersStore) {
return (dispatch, getState) => {
const searchFilter = getState().userList.searchFilter
if (!searchFilter || searchFilter.search_term.length >= minSearchLength || searchFilter.search_term === '') {
dispatch(this.loadingUsers())
store.load(searchFilter).then((response, _, xhr) => {
dispatch(this.gotUserList(response, xhr))
})
} else {
dispatch(this.displaySearchTermTooShortError(minSearchLength))
}
}
}
}

View File

@ -17,66 +17,48 @@
*/ */
import React from 'react' import React from 'react'
import PropTypes from 'prop-types' import {string, bool, shape} from 'prop-types'
import ReactTabs from 'react-tabs' import {stringify} from 'qs'
import permissionFilter from 'jsx/shared/helpers/permissionFilter' import permissionFilter from '../shared/helpers/permissionFilter'
import CoursesStore from './CoursesStore' import CoursesStore from './CoursesStore'
import TermsStore from './TermsStore' import TermsStore from './TermsStore'
import AccountsTreeStore from './AccountsTreeStore' import AccountsTreeStore from './AccountsTreeStore'
import UsersStore from './UsersStore' import UsersStore from './UsersStore'
const { Tab, Tabs, TabList, TabPanel } = ReactTabs
const { string, bool, shape } = PropTypes
const stores = [CoursesStore, TermsStore, AccountsTreeStore, UsersStore] const stores = [CoursesStore, TermsStore, AccountsTreeStore, UsersStore]
class AccountCourseUserSearch extends React.Component { export default class AccountCourseUserSearch extends React.Component {
static propTypes = { static propTypes = {
accountId: string.isRequired, accountId: string.isRequired,
permissions: shape({ permissions: shape({
theme_editor: bool.isRequired, analytics: bool.isRequired
analytics: bool.isRequired }).isRequired
}).isRequired
}
componentWillMount () {
stores.forEach((s) => {
s.reset({ accountId: this.props.accountId });
});
}
render () {
const { timezones, permissions, store } = this.props
const tabList = store.getState().tabList;
const tabs = permissionFilter(tabList.tabs, permissions);
const headers = tabs.map((tab, index) => {
return (
<Tab key={index}>
<a href={tabList.basePath + tab.path} title={tab.title}>{tab.title}</a>
</Tab>
);
});
const panels = tabs.map((tab, index) => {
const Pane = tab.pane;
return (
<TabPanel key={index}>
<Pane {...this.props} />
</TabPanel>
);
});
return (
<Tabs selectedIndex={tabList.selected}>
<TabList>
{headers}
</TabList>
{panels}
</Tabs>
);
}
} }
export default AccountCourseUserSearch componentWillMount() {
stores.forEach(s => {
s.reset({accountId: this.props.accountId})
})
}
updateQueryParams(params) {
const query = stringify(params)
window.history.replaceState(null, null, `?${query}`)
}
render() {
const tabList = this.props.store.getState().tabList
const tabs = permissionFilter(tabList.tabs, this.props.permissions)
const ActivePane = tabs[tabList.selected].pane
return (
<ActivePane
{...{
...this.props,
onUpdateQueryParams: this.updateQueryParams,
queryParams: tabList.queryParams
}}
/>
)
}
}

View File

@ -16,125 +16,125 @@
* with this program. If not, see <http://www.gnu.org/licenses/>. * with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import { combineReducers } from 'redux' import {combineReducers} from 'redux'
import _ from 'underscore'
import UserActions from '../actions/UserActions'
import parseLinkHeader from 'compiled/fn/parseLinkHeader' import parseLinkHeader from 'compiled/fn/parseLinkHeader'
import initialState from 'jsx/account_course_user_search/store/initialState' import initialState from '../store/initialState'
/** const emailRegex = /([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})/i
/**
* Handles setting the editUserDialogOpen state * Handles setting the editUserDialogOpen state
* state - the redux state * state - the redux state
* action - the redux action * action - the redux action
* visibility - boolean that editUserDialogOpen should be set to. * visibility - boolean that editUserDialogOpen should be set to.
*/ */
function setEditUserDialogOpenState (state, action, visibility) { function setEditUserDialogOpenState(state, action, visibility) {
const userObject = _.find(state.users, (user) => { return {
return user.id === action.payload.id; ...state,
}); users: state.users.map(user => {
if (user.id === action.payload.id) {
const userIndex = state.users.indexOf(userObject); return {...user, editUserDialogOpen: visibility}
if (userIndex > -1) { }
state.users[userIndex].editUserDialogOpen = visibility; return user
} })
return state;
} }
}
const userListHandlers = { const userListHandlers = {
ADD_ERROR: (state, action) => { ADD_ERROR(state, action) {
const errors = _.extend({}, state.errors); return {
state.errors = _.extend(errors, action.error); ...state,
return state; errors: {
}, ...state.errors,
ADD_TO_USERS: (state, action) => { ...action.error
if (action.payload.xhr) {
const {next} = parseLinkHeader(action.payload.xhr);
state.next = next;
} }
}
const mappedEmailUsers = action.payload.users.map((user) => { },
if (user.email) { ADD_TO_USERS: (state, action) => {
return user; const mappedEmailUsers = action.payload.users.map(user => {
} else { if (!user.email && emailRegex.test(user.login_id)) {
if (user.login_id && user.login_id.match(/([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})/i)) { return {...user, email: user.login_id}
user.email = user.login_id;
}
return user;
}
});
state.users = state.users.concat(mappedEmailUsers);
state.isLoading = false;
return state;
},
GOT_USERS: (state, action) => {
const { next } = parseLinkHeader(action.payload.xhr);
state.users = action.payload.users;
state.isLoading = false;
state.next = next;
return state;
},
GOT_USER_UPDATE: (state, action) => {
const userObject = _.find(state.users, (user) => {
return user.id === action.payload.id;
});
const userIndex = state.users.indexOf(userObject);
if (userIndex > -1) {
state.users[userIndex] = action.payload;
} }
return state; return user
}, })
OPEN_EDIT_USER_DIALOG: (state, action) => {
return setEditUserDialogOpenState(state, action, true); const newState = {
}, isLoading: false,
CLOSE_EDIT_USER_DIALOG: (state, action) => { users: state.users.concat(mappedEmailUsers)
return setEditUserDialogOpenState(state, action, false); }
}, if (action.payload.xhr) {
UPDATE_SEARCH_FILTER: (state, action) => { newState.next = parseLinkHeader(action.payload.xhr).next
state.searchFilter = _.extend({}, state.searchFilter, action.payload); }
state.errors = { return {...state, ...newState}
},
GOT_USERS(state, action) {
const {next} = parseLinkHeader(action.payload.xhr)
return {
...state,
users: action.payload.users,
isLoading: false,
next
}
},
GOT_USER_UPDATE(state, action) {
return {
...state,
users: state.users.map(user => (user.id === action.payload.id ? action.payload : user))
}
},
OPEN_EDIT_USER_DIALOG(state, action) {
return setEditUserDialogOpenState(state, action, true)
},
CLOSE_EDIT_USER_DIALOG(state, action) {
return setEditUserDialogOpenState(state, action, false)
},
UPDATE_SEARCH_FILTER(state, action) {
return {
...state,
errors: {
search_term: '' search_term: ''
}; },
return state; searchFilter: {
}, ...state.searchFilter,
SEARCH_TERM_TOO_SHORT: (state, action) => { ...action.payload
state.errors.search_term = action.errors.termTooShort; }
return state;
},
LOADING_USERS: (state, action) => {
state.isLoading = true;
return state;
} }
},
}; SEARCH_TERM_TOO_SHORT(state, action) {
return {
const userList = (state = initialState, action) => { ...state,
if (userListHandlers[action.type]) { errors: {
const newState = _.extend({}, state); ...state.errors,
return userListHandlers[action.type](newState, action); search_term: action.errors.termTooShort
} else { }
return state;
} }
}; },
LOADING_USERS(state, _action) {
const tabListHandlers = { return {
SELECT_TAB: (state, action) => { ...state,
state.selected = action.payload.tabIndex; isLoading: true
return state;
} }
}; }
}
const tabList = (state = initialState, action) => { const tabListHandlers = {
if (tabListHandlers[action.type]) { SELECT_TAB(state, action) {
const newState = _.extend({}, state); const {selected, queryParams} = action.payload
return tabListHandlers[action.type](newState, action); return {
} else { ...state,
return state; selected,
queryParams
} }
}; }
}
const makeReducer = handlerList => (state = initialState, action) => {
const handler = handlerList[action.type]
if (handler) return handler({...state}, action)
return state
}
export default combineReducers({ export default combineReducers({
userList, userList: makeReducer(userListHandlers),
tabList tabList: makeReducer(tabListHandlers)
}); })

View File

@ -1,60 +0,0 @@
/*
* Copyright (C) 2015 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from 'react'
import I18n from 'i18n!account_course_user_search'
function renderSearchMessage (collection, loadMore, noneFoundMessage) {
if (!collection || collection.loading) {
return (
<div className="text-center pad-box">
{I18n.t("Loading...")}
</div>
);
} else if (collection.error) {
return (
<div className="text-center pad-box">
<div className="alert alert-error">
{I18n.t("There was an error with your query; please try a different search")}
</div>
</div>
);
} else if (!collection.data.length) {
return (
<div className="text-center pad-box">
<div className="alert alert-info">
{noneFoundMessage}
</div>
</div>
);
} else if (collection.next) {
return (
<div className="text-center pad-box">
<button
className="Button--link load_more"
onClick={loadMore}
>
<i className="icon-refresh"/>
{" "}
{I18n.t("Load more...")}
</button>
</div>
);
}
}
export default renderSearchMessage

View File

@ -17,27 +17,26 @@
*/ */
import page from 'page' import page from 'page'
import {parse} from 'qs'
import TabActions from './actions/TabActions' import TabActions from './actions/TabActions'
export default {
start(store) {
const tabList = store.getState().tabList
const router = { page.base(tabList.basePath)
start: (store) => {
const tabList = store.getState().tabList;
page.base(tabList.basePath); tabList.tabs.forEach((tab, i) => {
page(tab.path, context => {
store.dispatch(
TabActions.selectTab({
selected: i,
queryParams: parse(context.querystring)
})
)
})
})
tabList.tabs.forEach((tab, i) => { page.start()
page(tab.path, (ctx) => { }
store.dispatch(TabActions.selectTab( i )); }
});
});
if (tabList.tabs.length)
page('/', tabList.tabs[0].path);
page.start();
}
};
export default router

View File

@ -18,22 +18,20 @@
import tabList from './tabList' import tabList from './tabList'
const initialState = { export default {
userList: { userList: {
users: [], users: [],
isLoading: true, isLoading: true,
errors: {search_term: ''}, errors: {search_term: ''},
next: undefined, next: undefined,
searchFilter: {search_term: ''}, searchFilter: {search_term: ''},
timezones: window.ENV.TIMEZONES, timezones: window.ENV.TIMEZONES,
permissions: window.ENV.PERMISSIONS, permissions: window.ENV.PERMISSIONS,
accountId: window.ENV.ACCOUNT_ID accountId: window.ENV.ACCOUNT_ID
}, },
tabList: { tabList: {
basePath: '', basePath: '',
tabs: tabList, tabs: tabList,
selected: 0 selected: 0
} }
}; }
export default initialState

View File

@ -20,20 +20,19 @@ import I18n from 'i18n!account_course_user_search'
import CoursesPane from '../CoursesPane' import CoursesPane from '../CoursesPane'
import UsersPane from '../UsersPane' import UsersPane from '../UsersPane'
const tabs = [ export default [
{ {
title: I18n.t('Courses'), pane: CoursesPane,
pane: CoursesPane, path: '',
path: '/courses', title: I18n.t('Courses'),
permissions: ['can_read_course_list'] permissions: ['can_read_course_list'],
}, button_class: 'courses'
{ },
title: I18n.t('People'), {
pane: UsersPane, pane: UsersPane,
path: '/people', path: '/users',
permissions: ['can_read_roster'] title: I18n.t('People'),
} permissions: ['can_read_roster'],
]; button_class: 'users'
}
]
export default tabs

View File

@ -16,6 +16,7 @@
* with this program. If not, see <http://www.gnu.org/licenses/>. * with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import $ from 'jquery'
import React from 'react' import React from 'react'
import ReactDOM from 'react-dom' import ReactDOM from 'react-dom'
import App from 'jsx/account_course_user_search/index' import App from 'jsx/account_course_user_search/index'
@ -23,28 +24,47 @@ import router from 'jsx/account_course_user_search/router'
import configureStore from 'jsx/account_course_user_search/store/configureStore' import configureStore from 'jsx/account_course_user_search/store/configureStore'
import initialState from 'jsx/account_course_user_search/store/initialState' import initialState from 'jsx/account_course_user_search/store/initialState'
if (location.pathname.indexOf(ENV.BASE_PATH) === -1) { // eg: '/accounts/xxx' for anything like '/accounts/xxx/whatever`
location.replace(ENV.BASE_PATH) initialState.tabList.basePath = window.location.pathname.match(/.*accounts\/[^/]*/)[0]
} else {
initialState.tabList.basePath = ENV.BASE_PATH
const content = document.getElementById('content') // Note. Only the UsersPane/Tab is using a redux store. The courses tab is
// still using the old store model. That is why this might seem kind of weird.
const store = configureStore(initialState)
// Note. Only the UsersPane/Tab is using a redux store. The courses tab is const props = {
// still using the old store model. That is why this might seem kind of weird. permissions: ENV.PERMISSIONS,
const store = configureStore(initialState) accountId: ENV.ACCOUNT_ID.toString(),
roles: Array.prototype.slice.call(ENV.COURSE_ROLES),
addUserUrls: ENV.URLS,
store
}
const options = { // this is where we take care of the 3 things we need to do outside of the
permissions: ENV.PERMISSIONS, // happy React/redux declarative/vDOM blessed path. It's so when we click
accountId: ENV.ACCOUNT_ID.toString(), // either the "Courses" or "People" tabs on the left, it highlights the right
roles: Array.prototype.slice.call(ENV.COURSE_ROLES), // tab and updates the crumb and document title
addUserUrls: ENV.URLS, const originalDocumentTitle = document.title
store function updateDocumentTitleBreadcrumbAndActiveTab(activeTab) {
} // give the correct left nav item an active class
$('#section-tabs .section a').each(function() {
store.subscribe(() => { const $tab = $(this)
ReactDOM.render(<App {...options} />, content) $tab[$tab.hasClass(activeTab.button_class) ? 'addClass' : 'removeClass']('active')
}) })
router.start(store) // update the page title
document.title = `${activeTab.title}: ${originalDocumentTitle}`
// toggle the breadcrumb between "Corses" and "People"
$('#breadcrumbs a:last span').text(activeTab.title)
} }
const content = document.getElementById('content')
store.subscribe(() => {
const tabState = store.getState().tabList
const selectedTab = tabState.tabs[tabState.selected]
updateDocumentTitleBreadcrumbAndActiveTab(selectedTab)
ReactDOM.render(<App {...props} />, content)
})
router.start(store)

View File

@ -17,18 +17,10 @@
*/ */
function permissionFilter(items, perms) { /**
return items.filter(item => { * filters `items` to just the ones who don't require any unavailable permissions.
let keep = true; */
export default (items, availablePermissions) => {
if (item.permissions && item.permissions.length) { const permissionIsAvailable = p => availablePermissions[p]
keep = item.permissions.reduce((prevPerm, curPerm) => { return items.filter(item => (item.permissions || []).every(permissionIsAvailable))
return prevPerm && perms[curPerm]; }
}, keep);
}
return keep;
});
};
export default permissionFilter

View File

@ -1373,13 +1373,7 @@ class Account < ActiveRecord::Base
manage_settings = user && self.grants_right?(user, :manage_account_settings) manage_settings = user && self.grants_right?(user, :manage_account_settings)
if root_account.site_admin? if root_account.site_admin?
tabs = [] tabs = []
if user && self.grants_right?(user, :read_roster) tabs << { :id => TAB_USERS, :label => t("People"), :css_class => 'users', :href => :account_users_path } if user && self.grants_right?(user, :read_roster)
if feature_enabled?(:course_user_search)
tabs << { :id => TAB_SEARCH, :label => t("Courses & People"), :css_class => 'search', :href => :account_course_user_search_path }
else
tabs << { :id => TAB_USERS, :label => t('#account.tab_users', "Users"), :css_class => 'users', :href => :account_users_path }
end
end
tabs << { :id => TAB_PERMISSIONS, :label => t('#account.tab_permissions', "Permissions"), :css_class => 'permissions', :href => :account_permissions_path } if user && self.grants_right?(user, :manage_role_overrides) tabs << { :id => TAB_PERMISSIONS, :label => t('#account.tab_permissions', "Permissions"), :css_class => 'permissions', :href => :account_permissions_path } if user && self.grants_right?(user, :manage_role_overrides)
tabs << { :id => TAB_SUB_ACCOUNTS, :label => t('#account.tab_sub_accounts', "Sub-Accounts"), :css_class => 'sub_accounts', :href => :account_sub_accounts_path } if manage_settings tabs << { :id => TAB_SUB_ACCOUNTS, :label => t('#account.tab_sub_accounts', "Sub-Accounts"), :css_class => 'sub_accounts', :href => :account_sub_accounts_path } if manage_settings
tabs << { :id => TAB_AUTHENTICATION, :label => t('#account.tab_authentication', "Authentication"), :css_class => 'authentication', :href => :account_authentication_providers_path } if root_account? && manage_settings tabs << { :id => TAB_AUTHENTICATION, :label => t('#account.tab_authentication', "Authentication"), :css_class => 'authentication', :href => :account_authentication_providers_path } if root_account? && manage_settings
@ -1387,12 +1381,8 @@ class Account < ActiveRecord::Base
tabs << { :id => TAB_JOBS, :label => t("#account.tab_jobs", "Jobs"), :css_class => "jobs", :href => :jobs_path, :no_args => true } if root_account? && self.grants_right?(user, :view_jobs) tabs << { :id => TAB_JOBS, :label => t("#account.tab_jobs", "Jobs"), :css_class => "jobs", :href => :jobs_path, :no_args => true } if root_account? && self.grants_right?(user, :view_jobs)
else else
tabs = [] tabs = []
if feature_enabled?(:course_user_search) tabs << { :id => TAB_COURSES, :label => t('#account.tab_courses', "Courses"), :css_class => 'courses', :href => :account_path } if user && self.grants_right?(user, :read_course_list)
tabs << { :id => TAB_SEARCH, :label => t("Courses & People"), :css_class => 'search', :href => :account_path } if user && (grants_right?(user, :read_course_list) || grants_right?(user, :read_roster)) tabs << { :id => TAB_USERS, :label => t("People"), :css_class => 'users', :href => :account_users_path } if user && self.grants_right?(user, :read_roster)
else
tabs << { :id => TAB_COURSES, :label => t('#account.tab_courses', "Courses"), :css_class => 'courses', :href => :account_path } if user && self.grants_right?(user, :read_course_list)
tabs << { :id => TAB_USERS, :label => t('#account.tab_users', "Users"), :css_class => 'users', :href => :account_users_path } if user && self.grants_right?(user, :read_roster)
end
tabs << { :id => TAB_STATISTICS, :label => t('#account.tab_statistics', "Statistics"), :css_class => 'statistics', :href => :statistics_account_path } if user && self.grants_right?(user, :view_statistics) tabs << { :id => TAB_STATISTICS, :label => t('#account.tab_statistics', "Statistics"), :css_class => 'statistics', :href => :statistics_account_path } if user && self.grants_right?(user, :view_statistics)
tabs << { :id => TAB_PERMISSIONS, :label => t('#account.tab_permissions', "Permissions"), :css_class => 'permissions', :href => :account_permissions_path } if user && self.grants_right?(user, :manage_role_overrides) tabs << { :id => TAB_PERMISSIONS, :label => t('#account.tab_permissions', "Permissions"), :css_class => 'permissions', :href => :account_permissions_path } if user && self.grants_right?(user, :manage_role_overrides)
if user && self.grants_right?(user, :manage_outcomes) if user && self.grants_right?(user, :manage_outcomes)

View File

@ -41,36 +41,6 @@
margin: 0; margin: 0;
} }
.react-tabs ul[role=tablist] {
padding-left: 10px;
margin-bottom: 20px;
border-color: $ic-border-light;
li[role=tab] {
font-size: 1.1em;
color: var(--ic-link-color);
padding: 0;
a {
padding: 10px 25px;
display: block;
color: inherit;
}
&[selected] {
font-weight: bold;
border-color: $ic-border-light;
a:link,
a:visited,
a:hover {
text-decoration: none;
color: $ic-font-color-dark;
}
}
}
}
.button-group { .button-group {
margin-right: 10px; margin-right: 10px;
&:last-of-type { &:last-of-type {

View File

@ -1,27 +0,0 @@
<%
# Copyright (C) 2015 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
%>
<%
@active_tab = "search"
content_for :page_title, @account.name
add_crumb t("Search"), account_path(@account)
js_bundle :account_course_user_search
css_bundle :account_course_user_search
js_env ACCOUNT_ID: @account.id,
PERMISSIONS: @permissions
%>

View File

@ -14,9 +14,6 @@
# #
# You should have received a copy of the GNU Affero General Public License along # 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/>. # with this program. If not, see <http://www.gnu.org/licenses/>.
%>
<%
@active_tab = "settings" @active_tab = "settings"
add_crumb t(:settings_crumb, "Settings") add_crumb t(:settings_crumb, "Settings")
js_bundle :account_settings js_bundle :account_settings
@ -33,10 +30,7 @@
} }
end end
%> %>
<% content_for :right_side do %> <% content_for :right_side do %>
<%= render :partial => "courses_right_side" unless @account.site_admin? %>
<%= render :partial => "additional_settings_right_side" %> <%= render :partial => "additional_settings_right_side" %>
<% end %> <% end %>
<h1 class='screenreader-only'><%= t(:page_header_title, "Account Settings") %></h1> <h1 class='screenreader-only'><%= t(:page_header_title, "Account Settings") %></h1>

View File

@ -14,19 +14,14 @@
# #
# You should have received a copy of the GNU Affero General Public License along # 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/>. # with this program. If not, see <http://www.gnu.org/licenses/>.
add_crumb t('titles.sub_accounts', "Sub-Accounts")
@active_tab = "sub_accounts"
content_for :page_title, t('titles.sub_accounts', 'Sub-Accounts')
css_bundle :sub_accounts
js_bundle :sub_accounts
%> %>
<% add_crumb t('titles.sub_accounts', "Sub-Accounts") %>
<% @active_tab = "sub_accounts" %>
<% content_for :page_title do %><%= t('titles.sub_accounts', 'Sub-Accounts') %><% end %>
<% content_for :right_side do %>
<%= render :partial => 'shared/accounts_right_side_shared' %>
<% end %>
<% css_bundle :sub_accounts %>
<% js_bundle :sub_accounts %>
<h1 class="screenreader-only"><%= t :page_header_title, 'Sub Accounts' %></h1> <h1 class="screenreader-only"><%= t :page_header_title, 'Sub Accounts' %></h1>
<div id="sub_account_urls"> <div id="sub_account_urls">
<a href="<%= context_url(@context, :context_sub_account_url, "{{ id }}") %>" class="sub_account_url" style="display: none;">&nbsp;</a> <a href="<%= context_url(@context, :context_sub_account_url, "{{ id }}") %>" class="sub_account_url" style="display: none;">&nbsp;</a>

View File

@ -749,7 +749,7 @@ CanvasRails::Application.routes.draw do
get 'search/bookmarks' => 'users#bookmark_search', as: :bookmark_search get 'search/bookmarks' => 'users#bookmark_search', as: :bookmark_search
get 'search/rubrics' => 'search#rubrics' get 'search/rubrics' => 'search#rubrics'
get 'search/all_courses' => 'search#all_courses' get 'search/all_courses' => 'search#all_courses'
resources :users, except: :destroy do resources :users, except: [:destroy, :index] do
match 'masquerade', via: [:get, :post] match 'masquerade', via: [:get, :post]
concerns :files, :file_images concerns :files, :file_images

View File

@ -46,7 +46,6 @@
"react-modal": "1.6.5", "react-modal": "1.6.5",
"react-redux": "4.4.5", "react-redux": "4.4.5",
"react-select-box": "https://github.com/instructure-react/react-select-box.git#b1ddd39223d48793fbe3dc4e87aca00d57197b5f", "react-select-box": "https://github.com/instructure-react/react-select-box.git#b1ddd39223d48793fbe3dc4e87aca00d57197b5f",
"react-tabs": "0.8.2",
"react-tokeninput": "2.4.0", "react-tokeninput": "2.4.0",
"react-tray": "2.0.4", "react-tray": "2.0.4",
"redux": "^3.5.2", "redux": "^3.5.2",

View File

@ -636,7 +636,6 @@ describe "security" do
get "/accounts/#{Account.default.id}/settings" get "/accounts/#{Account.default.id}/settings"
expect(response).to be_success expect(response).to be_success
expect(response.body).not_to match /Find A User/
get "/accounts/#{Account.default.id}/statistics" get "/accounts/#{Account.default.id}/statistics"
expect(response).to be_success expect(response).to be_success
@ -649,7 +648,6 @@ describe "security" do
get "/accounts/#{Account.default.id}/settings" get "/accounts/#{Account.default.id}/settings"
expect(response).to be_success expect(response).to be_success
expect(response.body).to match /Find A User/
get "/accounts/#{Account.default.id}/statistics" get "/accounts/#{Account.default.id}/statistics"
expect(response).to be_success expect(response).to be_success
@ -660,13 +658,9 @@ describe "security" do
add_permission :view_statistics add_permission :view_statistics
course_factory course_factory
get "/accounts/#{Account.default.id}"
expect(response).to be_redirect
get "/accounts/#{Account.default.id}/settings" get "/accounts/#{Account.default.id}/settings"
expect(response).to be_success expect(response).to be_success
expect(response.body).not_to match /Course Filtering/
expect(response.body).not_to match /Find a Course/
get "/accounts/#{Account.default.id}/statistics" get "/accounts/#{Account.default.id}/statistics"
expect(response).to be_success expect(response).to be_success
@ -678,8 +672,6 @@ describe "security" do
get "/accounts/#{Account.default.id}" get "/accounts/#{Account.default.id}"
expect(response).to be_success expect(response).to be_success
expect(response.body).to match /Courses/ expect(response.body).to match /Courses/
expect(response.body).to match /Course Filtering/
expect(response.body).to match /Find a Course/
get "/accounts/#{Account.default.id}/statistics" get "/accounts/#{Account.default.id}/statistics"
expect(response).to be_success expect(response).to be_success

View File

@ -35,6 +35,8 @@ test('onUpdateFilters calls debouncedApplyFilters after updating state', () => {
<CoursesPane <CoursesPane
accountId="1" accountId="1"
roles={[{id: '1' }]} roles={[{id: '1' }]}
queryParams={{}}
onUpdateQueryParams={function(){}}
addUserUrls={{ addUserUrls={{
USER_LISTS_URL: '/', USER_LISTS_URL: '/',
ENROLL_USERS_URL: '/', ENROLL_USERS_URL: '/',

View File

@ -48,6 +48,8 @@ test('handleUpdateSearchFilter dispatches applySearchFilter action', (assert) =>
<UsersPane <UsersPane
store={fakeStore} store={fakeStore}
roles={['a']} roles={['a']}
queryParams={{}}
onUpdateQueryParams={function(){}}
/> />
); );

View File

@ -16,281 +16,234 @@
* with this program. If not, see <http://www.gnu.org/licenses/>. * with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
define([ import actions from 'jsx/account_course_user_search/actions/UserActions'
'jsx/account_course_user_search/actions/UserActions', import UserStore from 'jsx/account_course_user_search/UsersStore'
'jsx/account_course_user_search/UsersStore'
], (actions, UserStore) => {
const STUDENTS = [ const STUDENTS = [
{ {
"id": "46", id: '46',
"name": "Irene Adler", name: 'Irene Adler',
"sortable_name": "Adler, Irene", sortable_name: 'Adler, Irene',
"short_name": "Irene Adler", short_name: 'Irene Adler',
"sis_user_id": "957", sis_user_id: '957',
"integration_id": null, integration_id: null,
"sis_login_id": "student57", sis_login_id: 'student57',
"sis_import_id": "1", sis_import_id: '1',
"login_id": "student57", login_id: 'student57',
"email": "brotherhood@example.com", email: 'brotherhood@example.com',
"last_login": null, last_login: null,
"time_zone": "Mountain Time (US & Canada)" time_zone: 'Mountain Time (US & Canada)'
}, },
{ {
"id": "44", id: '44',
"name": "Saint-John Allerdyce", name: 'Saint-John Allerdyce',
"sortable_name": "Allerdyce, Saint-John", sortable_name: 'Allerdyce, Saint-John',
"short_name": "Saint-John Allerdyce", short_name: 'Saint-John Allerdyce',
"sis_user_id": "955", sis_user_id: '955',
"integration_id": null, integration_id: null,
"sis_login_id": "student55", sis_login_id: 'student55',
"sis_import_id": "1", sis_import_id: '1',
"login_id": "student55", login_id: 'student55',
"email": "brotherhood@example.com", email: 'brotherhood@example.com',
"last_login": null, last_login: null,
"time_zone": "Mountain Time (US & Canada)" time_zone: 'Mountain Time (US & Canada)'
}, },
{ {
"id": "52", id: '52',
"name": "Michael Baer", name: 'Michael Baer',
"sortable_name": "Baer, Michael", sortable_name: 'Baer, Michael',
"short_name": "Michael Baer", short_name: 'Michael Baer',
"sis_user_id": "963", sis_user_id: '963',
"integration_id": null, integration_id: null,
"sis_login_id": "student63", sis_login_id: 'student63',
"sis_import_id": "1", sis_import_id: '1',
"login_id": "student63", login_id: 'student63',
"email": "marauders@example.com", email: 'marauders@example.com',
"last_login": null, last_login: null,
"time_zone": "Mountain Time (US & Canada)" time_zone: 'Mountain Time (US & Canada)'
}]; }
]
QUnit.module('Account Course User Search Actions'); QUnit.module('Account Course User Search Actions')
asyncTest('apiCreateUser', () => { test('apiCreateUser', function(assert) {
const server = sinon.fakeServer.create(); assert.expect(3)
UserStore.reset({accountId: 1}); const server = sinon.fakeServer.create()
UserStore.reset({accountId: 1})
server.respondWith('POST', '/api/v1/accounts/1/users', server.respondWith('POST', '/api/v1/accounts/1/users', [
[200, { "Content-Type": "application/json" }, JSON.stringify(STUDENTS[0])] 200,
); {'Content-Type': 'application/json'},
JSON.stringify(STUDENTS[0])
])
equal(typeof actions.apiCreateUser(1, {}), 'function', 'it initally returns a callback function'); equal(typeof actions.apiCreateUser(1, {}), 'function', 'it initally returns a callback function')
actions.apiCreateUser(1, {})((response) => { actions.apiCreateUser(1, {})(response => {
equal(response.type, 'ADD_TO_USERS', 'it dispatches the proper action'); equal(response.type, 'ADD_TO_USERS', 'it dispatches the proper action')
equal(Array.isArray(response.payload.users), true, 'it returns a users array'); equal(Array.isArray(response.payload.users), true, 'it returns a users array')
start(); })
});
server.respond(); server.respond()
server.restore(); server.restore()
}); })
test('addError', () => { test('addError', () => {
const message = actions.addError({errorKey: 'error'}); const message = actions.addError({errorKey: 'error'})
equal(message.type, 'ADD_ERROR', 'it returns the proper action type'); equal(message.type, 'ADD_ERROR', 'it returns the proper action type')
deepEqual(message.error, {errorKey: 'error'}, 'it returns the proper error'); deepEqual(message.error, {errorKey: 'error'}, 'it returns the proper error')
}); })
asyncTest('apiGetUsers', () => { asyncTest('apiUpdateUser', () => {
const server = sinon.fakeServer.create()
// This will let us start the tests back once all the async stuff finishes. // This is a POST rather than a PUT because of the way our $.getJSON converts
let counter = 2; // non-GET requests to posts anyways.
function done () { server.respondWith('POST', /api\/v1\/users\/1/, [
--counter || start(); 200,
{'Content-Type': 'application/json'},
JSON.stringify(STUDENTS[0])
])
equal(
typeof actions.apiUpdateUser({name: 'Test'}, 1),
'function',
'it initally returns a callback function'
)
actions.apiUpdateUser({name: 'Test'}, 1)(response => {
equal(response.type, 'GOT_USER_UPDATE', 'it returns the proper action type')
deepEqual(response.payload, STUDENTS[0], 'it returns the user in the payload')
start()
})
server.respond()
server.restore()
})
test('openEditUserDialog', () => {
const message = actions.openEditUserDialog(STUDENTS[0])
equal(message.type, 'OPEN_EDIT_USER_DIALOG', 'it returns the proper type')
deepEqual(message.payload, STUDENTS[0], 'the payload contains the user')
})
test('closeEditUserDialog', () => {
const message = actions.closeEditUserDialog(STUDENTS[0])
equal(message.type, 'CLOSE_EDIT_USER_DIALOG', 'it returns the proper type')
deepEqual(message.payload, STUDENTS[0], 'the payload contains the user')
})
test('updateSearchFilter', () => {
const message = actions.updateSearchFilter('myFilter')
equal(message.type, 'UPDATE_SEARCH_FILTER', 'it returns the proper type')
equal(message.payload, 'myFilter', 'the payload contains the filter')
})
test('displaySearchTermTooShortError', () => {
const message = actions.displaySearchTermTooShortError(3)
equal(message.type, 'SEARCH_TERM_TOO_SHORT', 'it returns the proper type')
equal(
message.errors.termTooShort,
'Search term must be at least 3 characters',
'the error is set with the proper number'
)
})
test('loadingUsers', () => {
const message = actions.loadingUsers()
equal(message.type, 'LOADING_USERS', 'it returns the proper type')
})
asyncTest('getMoreUsers', () => {
let count = 2
const done = () => {
--count || start()
}
const fakeDispatcher = response => {
if (count === 2) {
equal(response.type, 'LOADING_USERS', 'it returns the proper action type')
done()
} else {
equal(response.type, 'ADD_TO_USERS', 'it returns the proper action type')
deepEqual(response.payload.users[0], STUDENTS[0], 'it returns the user in the payload')
done()
} }
}
const server = sinon.fakeServer.create(); const fakeGetState = () => ({
UserStore.reset({accountId: 1}); userList: {
searchFilter: 'abc',
next: '/api/v1/accounts/1/users?page=2'
}
})
server.respondWith('GET', /api\/v1\/accounts\/1\/users/, const fakeUserStore = {
[200, { "Content-Type": "application/json" }, JSON.stringify(STUDENTS)] loadMore(filter) {
); return Promise.resolve([STUDENTS[0]])
}
}
equal(typeof actions.apiGetUsers(), 'function', 'it initally returns a callback function'); const actionThunk = actions.getMoreUsers(fakeUserStore)
actions.apiGetUsers()((response) => { equal(typeof actionThunk, 'function', 'it initally returns a callback function')
equal(response.type, 'GOT_USERS', 'it returns the proper action type');
deepEqual(response.payload.users, STUDENTS, 'it returns the proper data');
ok(response.payload.xhr, 'it calls out to the api when state has no users');
done();
}, () => {
return {
userList: {
users: []
}
}
});
actions.apiGetUsers()((response) => { actionThunk(fakeDispatcher, fakeGetState)
deepEqual(response.payload.users, STUDENTS, 'it returns the proper data'); })
ok(!response.payload.xhr, 'it does not call the api when there is state in the store');
done();
}, () => {
return {
userList: {
users: STUDENTS
}
}
});
server.respond(); asyncTest('applySearchFilter', () => {
server.restore(); let count = 3
const done = () => {
--count || start()
}
}); const fakeDispatcherSearchLengthOkay = response => {
if (count === 3) {
equal(response.type, 'LOADING_USERS', 'it returns the proper action type')
done()
} else {
equal(response.type, 'GOT_USERS', 'it returns the proper action type')
deepEqual(response.payload.users[0], STUDENTS[0], 'it returns the user in the payload')
done()
}
}
asyncTest('apiUpdateUser', () => { const fakeGetStateSearchLengthOkay = () => ({
const server = sinon.fakeServer.create(); userList: {
searchFilter: {
// This is a POST rather than a PUT because of the way our $.getJSON converts search_term: 'abcd'
// non-GET requests to posts anyways.
server.respondWith('POST', /api\/v1\/users\/1/,
[200, { "Content-Type": "application/json" }, JSON.stringify(STUDENTS[0])]
);
equal(typeof actions.apiUpdateUser({name: 'Test'}, 1), 'function', 'it initally returns a callback function');
actions.apiUpdateUser({name: 'Test'}, 1)((response) => {
equal(response.type, 'GOT_USER_UPDATE', 'it returns the proper action type');
deepEqual(response.payload, STUDENTS[0], 'it returns the user in the payload');
start();
});
server.respond();
server.restore();
});
test('openEditUserDialog', () => {
const message = actions.openEditUserDialog(STUDENTS[0]);
equal(message.type, 'OPEN_EDIT_USER_DIALOG', 'it returns the proper type');
deepEqual(message.payload, STUDENTS[0], 'the payload contains the user');
});
test('closeEditUserDialog', () => {
const message = actions.closeEditUserDialog(STUDENTS[0]);
equal(message.type, 'CLOSE_EDIT_USER_DIALOG', 'it returns the proper type');
deepEqual(message.payload, STUDENTS[0], 'the payload contains the user');
});
test('updateSearchFilter', () => {
const message = actions.updateSearchFilter('myFilter');
equal(message.type, 'UPDATE_SEARCH_FILTER', 'it returns the proper type');
equal(message.payload, 'myFilter', 'the payload contains the filter');
});
test('displaySearchTermTooShortError', () => {
const message = actions.displaySearchTermTooShortError(3);
equal(message.type, 'SEARCH_TERM_TOO_SHORT', 'it returns the proper type');
equal(message.errors.termTooShort, 'Search term must be at least 3 characters', 'the error is set with the proper number');
});
test('loadingUsers', () => {
const message = actions.loadingUsers();
equal(message.type, 'LOADING_USERS', 'it returns the proper type');
});
asyncTest('getMoreUsers', () => {
let count = 2;
const done = () => {
--count || start();
};
const fakeDispatcher = (response) => {
if (count === 2) {
equal(response.type, 'LOADING_USERS', 'it returns the proper action type');
done();
} else {
equal(response.type, 'ADD_TO_USERS', 'it returns the proper action type');
deepEqual(response.payload.users[0], STUDENTS[0], 'it returns the user in the payload');
done();
}
};
const fakeGetState = () => {
return {
userList: {
searchFilter: 'abc',
next: '/api/v1/accounts/1/users?page=2'
}
};
};
const fakeUserStore = {
loadMore (filter) {
return Promise.resolve([STUDENTS[0]])
} }
} }
})
const actionThunk = actions.getMoreUsers(fakeUserStore); const fakeDispatcherSearchLengthTooShort = response => {
equal(response.type, 'SEARCH_TERM_TOO_SHORT', 'it returns the proper action type')
equal(
response.errors.termTooShort,
'Search term must be at least 4 characters',
'the error is set with the proper number'
)
done()
}
equal(typeof actionThunk, 'function', 'it initally returns a callback function'); const fakeGetStateSearchLengthTooShort = () => ({
userList: {
actionThunk(fakeDispatcher, fakeGetState); searchFilter: {
search_term: 'a'
});
asyncTest('applySearchFilter', () => {
let count = 3;
const done = () => {
--count || start();
};
const fakeDispatcherSearchLengthOkay = (response) => {
if (count === 3) {
equal(response.type, 'LOADING_USERS', 'it returns the proper action type');
done();
} else {
equal(response.type, 'GOT_USERS', 'it returns the proper action type');
deepEqual(response.payload.users[0], STUDENTS[0], 'it returns the user in the payload');
done();
}
};
const fakeGetStateSearchLengthOkay = () => {
return {
userList: {
searchFilter: {
search_term: 'abcd'
}
}
};
};
const fakeDispatcherSearchLengthTooShort = (response) => {
equal(response.type, 'SEARCH_TERM_TOO_SHORT', 'it returns the proper action type');
equal(response.errors.termTooShort, 'Search term must be at least 4 characters', 'the error is set with the proper number');
done();
};
const fakeGetStateSearchLengthTooShort = () => {
return {
userList: {
searchFilter: {
search_term: 'a'
}
}
};
};
const fakeUserStore = {
load (filter) {
return Promise.resolve([STUDENTS[0]]);
} }
} }
})
const actionThunk = actions.applySearchFilter(4, fakeUserStore); const fakeUserStore = {
load() {
return Promise.resolve([STUDENTS[0]])
}
}
equal(typeof actionThunk, 'function', 'it initally returns a callback function'); const actionThunk = actions.applySearchFilter(4, fakeUserStore)
actionThunk(fakeDispatcherSearchLengthOkay, fakeGetStateSearchLengthOkay); equal(typeof actionThunk, 'function', 'it initally returns a callback function')
actionThunk(fakeDispatcherSearchLengthTooShort, fakeGetStateSearchLengthTooShort);
});
actionThunk(fakeDispatcherSearchLengthOkay, fakeGetStateSearchLengthOkay)
}); actionThunk(fakeDispatcherSearchLengthTooShort, fakeGetStateSearchLengthTooShort)
})

View File

@ -327,7 +327,7 @@ define(['jsx/account_course_user_search/reducers/rootReducer'], (reducer) => {
const action = { const action = {
type: 'SELECT_TAB', type: 'SELECT_TAB',
payload: { payload: {
tabIndex: 1 selected: 1
} }
}; };

View File

@ -38,8 +38,7 @@ describe "new account course search" do
@account.role_overrides.create! :role => admin_role, :permission => 'read_course_list', :enabled => false @account.role_overrides.create! :role => admin_role, :permission => 'read_course_list', :enabled => false
get "/accounts/#{@account.id}" get "/accounts/#{@account.id}"
expect(f("#left-side #section-tabs")).not_to include_text("Courses")
expect(f(".react-tabs > ul")).to_not include_text("Courses")
end end
it "should hide courses without enrollments if checked" do it "should hide courses without enrollments if checked" do

View File

@ -34,33 +34,47 @@ describe "new account user search" do
ff('.users-list div[role=row]') ff('.users-list div[role=row]')
end end
def click_tab it "should be able to toggle between 'People' and 'Courses' tabs" do
ff(".react-tabs > ul li").detect{|tab| tab.text.include?("People")}.click user_with_pseudonym(:account => @account, :name => "Test User")
wait_for_ajaximations course_factory(:account => @account, :course_name => "Test Course")
get "/accounts/#{@account.id}"
2.times do
expect(f("#breadcrumbs")).not_to include_text("People")
expect(f("#breadcrumbs")).to include_text("Courses")
expect(f('.courses-list')).to include_text("Test Course")
f('#section-tabs .users').click
expect(driver.current_url).to include("/accounts/#{@account.id}/users")
expect(f("#breadcrumbs")).to include_text("People")
expect(f("#breadcrumbs")).not_to include_text("Courses")
expect(f('.users-list')).to include_text("Test User")
f('#section-tabs .courses').click
end
end end
it "should not show the people tab without permission" do it "should not show the people tab without permission" do
@account.role_overrides.create! :role => admin_role, :permission => 'read_roster', :enabled => false @account.role_overrides.create! :role => admin_role, :permission => 'read_roster', :enabled => false
get "/accounts/#{@account.id}" get "/accounts/#{@account.id}"
expect(f(".react-tabs > ul")).to_not include_text("People") expect(f("#left-side #section-tabs")).not_to include_text("People")
end end
it "should not show the create users button for non-root acocunts" do it "should not show the create users button for non-root acocunts" do
sub_account = Account.create!(:name => "sub", :parent_account => @account) sub_account = Account.create!(:name => "sub", :parent_account => @account)
get "/accounts/#{sub_account.id}" get "/accounts/#{sub_account.id}/users"
click_tab
expect(f("#content")).not_to contain_css('button.add_user') expect(f("#content")).not_to contain_css('button.add_user')
end end
it "should be able to create users" do it "should be able to create users" do
get "/accounts/#{@account.id}" get "/accounts/#{@account.id}/users"
click_tab
f('button.add_user').click f('button.add_user').click
@ -94,8 +108,7 @@ describe "new account user search" do
user_with_pseudonym(:account => @account, :name => "Test User #{x + 1}") user_with_pseudonym(:account => @account, :name => "Test User #{x + 1}")
end end
get "/accounts/#{@account.id}" get "/accounts/#{@account.id}/users"
click_tab
expect(get_rows.count).to eq 10 expect(get_rows.count).to eq 10
@ -110,8 +123,7 @@ describe "new account user search" do
match_user = user_with_pseudonym(:account => @account, :name => "user with a search term") match_user = user_with_pseudonym(:account => @account, :name => "user with a search term")
user_with_pseudonym(:account => @account, :name => "diffrient user") user_with_pseudonym(:account => @account, :name => "diffrient user")
get "/accounts/#{@account.id}" get "/accounts/#{@account.id}/users"
click_tab
f('.user_search_bar input[type=search]').send_keys('search') f('.user_search_bar input[type=search]').send_keys('search')
@ -125,8 +137,7 @@ describe "new account user search" do
match_user = user_with_pseudonym(:account => @account, :name => "user with a search term") match_user = user_with_pseudonym(:account => @account, :name => "user with a search term")
user_with_pseudonym(:account => @account, :name => "diffrient user") user_with_pseudonym(:account => @account, :name => "diffrient user")
get "/accounts/#{@account.id}" get "/accounts/#{@account.id}/users"
click_tab
f('#peopleOptionsBtn').click f('#peopleOptionsBtn').click
f('#manageStudentsLink').click f('#manageStudentsLink').click
@ -138,8 +149,7 @@ describe "new account user search" do
match_user = user_with_pseudonym(:account => @account, :name => "user with a search term") match_user = user_with_pseudonym(:account => @account, :name => "user with a search term")
user_with_pseudonym(:account => @account, :name => "diffrient user") user_with_pseudonym(:account => @account, :name => "diffrient user")
get "/accounts/#{@account.id}" get "/accounts/#{@account.id}/users"
click_tab
f('#peopleOptionsBtn').click f('#peopleOptionsBtn').click
f('#viewUserGroupLink').click f('#viewUserGroupLink').click

View File

@ -1,34 +0,0 @@
#
# Copyright (C) 2012 - 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/>.
require File.expand_path(File.dirname(__FILE__) + '/../../common')
require File.expand_path(File.dirname(__FILE__) + '/../../helpers/basic/users_specs')
describe "sub account users" do
describe "shared users specs" do
let(:account) { Account.create(:name => 'sub account from default account', :parent_account => Account.default) }
let(:url) { "/accounts/#{account.id}/users" }
let(:opts) { {:name => 'student'} }
include_examples "users basic tests"
it "does not show the add user link for sub-accounts", priority: '2', test_id: 854797 do
course_with_admin_logged_in
get url
expect(f('#right-side')).not_to contain_css('.add_user_link')
end
end
end

View File

@ -127,6 +127,8 @@ describe "conversations new" do
it "should allow admins to message users from their profiles", priority: "2", test_id: 201940 do it "should allow admins to message users from their profiles", priority: "2", test_id: 201940 do
user = account_admin_user user = account_admin_user
user_logged_in({:user => user}) user_logged_in({:user => user})
# TODO: delete these lines when we remove the :course_user_search feature flag
get "/accounts/#{Account.default.id}/users" get "/accounts/#{Account.default.id}/users"
wait_for_ajaximations wait_for_ajaximations
f('li.user a').click f('li.user a').click
@ -134,6 +136,13 @@ describe "conversations new" do
f('.icon-email').click f('.icon-email').click
wait_for_ajaximations wait_for_ajaximations
expect(f('.ac-token')).not_to be_nil expect(f('.ac-token')).not_to be_nil
Account.default.enable_feature!(:course_user_search)
get "/accounts/#{Account.default.id}/users"
wait_for_ajaximations
f('.users-list [role=row] .Button .icon-message').click
wait_for_ajaximations
expect(f('.ac-token')).not_to be_nil
end end
it "should allow selecting multiple recipients in one search", priority: "2", test_id: 201941 do it "should allow selecting multiple recipients in one search", priority: "2", test_id: 201941 do

View File

@ -4777,10 +4777,6 @@ js-base64@^2.1.8, js-base64@^2.1.9, js-base64@~2.1.8:
version "2.1.9" version "2.1.9"
resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-2.1.9.tgz#f0e80ae039a4bd654b5f281fc93f04a914a7fcce" resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-2.1.9.tgz#f0e80ae039a4bd654b5f281fc93f04a914a7fcce"
js-stylesheet@^0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/js-stylesheet/-/js-stylesheet-0.0.1.tgz#12cc1451220e454184b46de3b098c0d154762c38"
js-tokens@1.0.1: js-tokens@1.0.1:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-1.0.1.tgz#cc435a5c8b94ad15acb7983140fc80182c89aeae" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-1.0.1.tgz#cc435a5c8b94ad15acb7983140fc80182c89aeae"
@ -7052,13 +7048,6 @@ react-redux@^5.0.3:
version "2.0.1" version "2.0.1"
resolved "https://github.com/instructure-react/react-select-box.git#b1ddd39223d48793fbe3dc4e87aca00d57197b5f" resolved "https://github.com/instructure-react/react-select-box.git#b1ddd39223d48793fbe3dc4e87aca00d57197b5f"
react-tabs@0.8.2:
version "0.8.2"
resolved "https://registry.yarnpkg.com/react-tabs/-/react-tabs-0.8.2.tgz#7928e822361b61eb7f53164daf86ad7ee98ef539"
dependencies:
classnames "^2.2.0"
js-stylesheet "^0.0.1"
react-tinymce@0.6.0: react-tinymce@0.6.0:
version "0.6.0" version "0.6.0"
resolved "https://registry.yarnpkg.com/react-tinymce/-/react-tinymce-0.6.0.tgz#729cd1edd00c4214b7d253f02283a7d2b52813aa" resolved "https://registry.yarnpkg.com/react-tinymce/-/react-tinymce-0.6.0.tgz#729cd1edd00c4214b7d253f02283a7d2b52813aa"