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
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
#
# List accounts that are sub-accounts of the given account.
@ -1169,16 +1132,6 @@ class AccountsController < ApplicationController
rce_js_env(:basic)
end
def localized_timezones(timezones)
timezones.map do |timezone|
{
name: timezone.name,
localized_name: timezone.to_s
}
end
end
private :localized_timezones
private
def ensure_sis_max_name_length_value!(account_settings)

View File

@ -2279,4 +2279,57 @@ class ApplicationController < ActionController::Base
def teardown_live_events_context
LiveEvents.clear_context!
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

View File

@ -390,6 +390,11 @@ class UsersController < ApplicationController
# @returns [User]
def index
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)
@root_account = @context.root_account
@query = (params[:user] && params[:user][:name]) || params[:term]

View File

@ -150,7 +150,7 @@ class CoursesList extends React.Component {
</div>
<div className="courses-list" role="rowgroup">
{courses.map((course) => {
{(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)

View File

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

View File

@ -17,8 +17,9 @@
*/
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 preventDefault from 'compiled/fn/preventDefault'
import TermsStore from './TermsStore'
import AccountsTreeStore from './AccountsTreeStore'
import NewCourseModal from './NewCourseModal'
@ -26,17 +27,35 @@ import IcInput from './IcInput'
import IcSelect from './IcSelect'
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 = {
onUpdateFilters: func.isRequired,
onApplyFilters: func.isRequired,
isLoading: bool,
with_students: bool.isRequired,
search_term: string,
enrollment_term_id: string,
sortColumn: string,
isLoading: bool.isRequired,
draftFilters: shape({
with_students: bool.isRequired,
search_by: oneOf(['course', 'teacher']).isRequired,
search_term: string.isRequired,
enrollment_term_id: string.isRequired,
}).isRequired,
errors: shape({ search_term: string }).isRequired,
terms: arrayOf(TermsStore.PropType),
accounts: arrayOf(AccountsTreeStore.PropType),
@ -45,42 +64,15 @@ class CoursesToolbar extends React.Component {
static defaultProps = {
terms: null,
accounts: [],
search_term: '',
enrollment_term_id: null,
isLoading: false,
sortColumn: ''
}
applyFilters = (e) => {
e.preventDefault()
this.props.onApplyFilters()
}
addCourse = () => {
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 () {
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 ?
(<div>
@ -96,31 +88,36 @@ class CoursesToolbar extends React.Component {
<form
className="course_search_bar"
style={{opacity: isLoading ? 0.5 : 1}}
onSubmit={this.applyFilters}
onSubmit={preventDefault(this.props.onApplyFilters)}
disabled={isLoading}
>
<div className="ic-Form-action-box courses-list-search-bar-layout">
<div className="ic-Form-action-box__Form">
<IcSelect
value={props.enrollment_term_id}
value={draftFilters.enrollment_term_id}
onChange={e => onUpdateFilters({enrollment_term_id: e.target.value})}
>
{this.renderTerms()}
<TermOpts terms={terms} />
</IcSelect>
<IcSelect
value={props.search_by}
value={draftFilters.search_by}
onChange={e => onUpdateFilters({search_by: e.target.value})}
>
<option key="course" value="course">
{I18n.t('Course')}
</option>
<option key="teacher" value="teacher">
{I18n.t('Teacher')}
</option>
<optgroup label={I18n.t('Search By')}>
<option key="course" value="course">
{I18n.t('Course')}
</option>
<option key="teacher" value="teacher">
{I18n.t('Teacher')}
</option>
</optgroup>
</IcSelect>
<IcInput
value={props.search_term}
placeholder={I18n.t('Search courses...')}
value={draftFilters.search_term}
placeholder={draftFilters.search_by === 'teacher' ?
I18n.t('Search courses by teacher...') :
I18n.t('Search courses...')
}
onChange={e => onUpdateFilters({search_term: e.target.value})}
error={errors.search_term}
type="search"
@ -131,7 +128,7 @@ class CoursesToolbar extends React.Component {
</div>
</div>
<IcCheckbox
checked={props.with_students}
checked={draftFilters.with_students}
onChange={e => onUpdateFilters({with_students: e.target.checked})}
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 PropTypes from 'prop-types'
import I18n from 'i18n!account_course_user_search'
import {string} from 'prop-types'
var UserLink = React.createClass({
propTypes: {
id: PropTypes.string.isRequired,
display_name: PropTypes.string.isRequired,
avatar_image_url: PropTypes.string
},
export default function UserLink ({ id, display_name, avatar_image_url }) {
const url = `/users/${id}`
return (
<div className="ellipsis">
{!!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() {
var { id, display_name, avatar_image_url } = this.props;
var url = `/users/${id}`;
return (
<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
UserLink.propTypes = {
id: string.isRequired,
display_name: string.isRequired,
avatar_image_url: string
}

View File

@ -17,25 +17,30 @@
*/
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 _ from 'underscore'
import UsersStore from './UsersStore'
import UsersList from './UsersList'
import UsersToolbar from './UsersToolbar'
import renderSearchMessage from './renderSearchMessage'
import SearchMessage from './SearchMessage'
import UserActions from './actions/UserActions'
const MIN_SEARCH_LENGTH = 3;
class UsersPane extends React.Component {
export default class UsersPane extends React.Component {
static propTypes = {
store: PropTypes.shape({
getState: PropTypes.func.isRequired,
dispatch: PropTypes.func.isRequired,
subscribe: PropTypes.func.isRequired,
store: shape({
getState: func.isRequired,
dispatch: func.isRequired,
subscribe: func.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) {
@ -48,7 +53,12 @@ class UsersPane extends React.Component {
componentDidMount = () => {
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 = () => {
@ -64,12 +74,16 @@ class UsersPane extends React.Component {
}
handleApplyingSearchFilter = () => {
this.props.store.dispatch(UserActions.applySearchFilter(MIN_SEARCH_LENGTH));
this.props.store.dispatch(UserActions.applySearchFilter(MIN_SEARCH_LENGTH))
this.updateQueryString()
}
debouncedDispatchApplySearchFilter = _.debounce(() => {
this.props.store.dispatch(UserActions.applySearchFilter(MIN_SEARCH_LENGTH));
}, 250);
updateQueryString = () => {
const searchFilter = this.props.store.getState().userList.searchFilter
this.props.onUpdateQueryParams(searchFilter)
}
debouncedDispatchApplySearchFilter = _.debounce(this.handleApplyingSearchFilter, 250)
handleUpdateSearchFilter = (searchFilter) => {
this.props.store.dispatch(UserActions.updateSearchFilter(searchFilter));
@ -97,14 +111,13 @@ class UsersPane extends React.Component {
}
handleAddNewUserFormErrors = (errors) => {
for (const key in errors) {
this.props.store.dispatch(UserActions.addError({[key]: errors[key]}));
}
Object.keys(errors).forEach(key => {
this.props.store.dispatch(UserActions.addError({[key]: errors[key]}))
})
}
render () {
const {next, timezones, accountId, users, isLoading, errors, searchFilter} = this.state.userList;
const collection = {data: users, loading: isLoading, next};
const {next, timezones, accountId, users, isLoading, errors, searchFilter} = this.state.userList
return (
<div>
{<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>
);
)
}
}
export default UsersPane

View File

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

View File

@ -18,136 +18,120 @@
import $ from 'jquery'
import UsersStore from 'jsx/account_course_user_search/UsersStore'
import _ from 'underscore'
import I18n from 'i18n!user_actions'
const UserActions = {
apiCreateUser (accountId, attributes) {
return (dispatch, getState) => {
UsersStore.create(attributes).then((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 {
apiCreateUser(accountId, attributes) {
return (dispatch, _getState) => {
UsersStore.create(attributes).then((response, _, xhr) => {
dispatch(this.addToUsers([response], xhr))
})
}
};
},
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 PropTypes from 'prop-types'
import ReactTabs from 'react-tabs'
import permissionFilter from 'jsx/shared/helpers/permissionFilter'
import {string, bool, shape} from 'prop-types'
import {stringify} from 'qs'
import permissionFilter from '../shared/helpers/permissionFilter'
import CoursesStore from './CoursesStore'
import TermsStore from './TermsStore'
import AccountsTreeStore from './AccountsTreeStore'
import UsersStore from './UsersStore'
const { Tab, Tabs, TabList, TabPanel } = ReactTabs
const { string, bool, shape } = PropTypes
const stores = [CoursesStore, TermsStore, AccountsTreeStore, UsersStore]
class AccountCourseUserSearch extends React.Component {
static propTypes = {
accountId: string.isRequired,
permissions: shape({
theme_editor: bool.isRequired,
analytics: bool.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 class AccountCourseUserSearch extends React.Component {
static propTypes = {
accountId: string.isRequired,
permissions: shape({
analytics: bool.isRequired
}).isRequired
}
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/>.
*/
import { combineReducers } from 'redux'
import _ from 'underscore'
import UserActions from '../actions/UserActions'
import {combineReducers} from 'redux'
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
* state - the redux state
* action - the redux action
* visibility - boolean that editUserDialogOpen should be set to.
*/
function setEditUserDialogOpenState (state, action, visibility) {
const userObject = _.find(state.users, (user) => {
return user.id === action.payload.id;
});
const userIndex = state.users.indexOf(userObject);
if (userIndex > -1) {
state.users[userIndex].editUserDialogOpen = visibility;
}
return state;
function setEditUserDialogOpenState(state, action, visibility) {
return {
...state,
users: state.users.map(user => {
if (user.id === action.payload.id) {
return {...user, editUserDialogOpen: visibility}
}
return user
})
}
}
const userListHandlers = {
ADD_ERROR: (state, action) => {
const errors = _.extend({}, state.errors);
state.errors = _.extend(errors, action.error);
return state;
},
ADD_TO_USERS: (state, action) => {
if (action.payload.xhr) {
const {next} = parseLinkHeader(action.payload.xhr);
state.next = next;
const userListHandlers = {
ADD_ERROR(state, action) {
return {
...state,
errors: {
...state.errors,
...action.error
}
const mappedEmailUsers = action.payload.users.map((user) => {
if (user.email) {
return user;
} else {
if (user.login_id && user.login_id.match(/([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})/i)) {
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;
}
},
ADD_TO_USERS: (state, action) => {
const mappedEmailUsers = action.payload.users.map(user => {
if (!user.email && emailRegex.test(user.login_id)) {
return {...user, email: user.login_id}
}
return state;
},
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) => {
state.searchFilter = _.extend({}, state.searchFilter, action.payload);
state.errors = {
return user
})
const newState = {
isLoading: false,
users: state.users.concat(mappedEmailUsers)
}
if (action.payload.xhr) {
newState.next = parseLinkHeader(action.payload.xhr).next
}
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: ''
};
return state;
},
SEARCH_TERM_TOO_SHORT: (state, action) => {
state.errors.search_term = action.errors.termTooShort;
return state;
},
LOADING_USERS: (state, action) => {
state.isLoading = true;
return state;
},
searchFilter: {
...state.searchFilter,
...action.payload
}
}
};
const userList = (state = initialState, action) => {
if (userListHandlers[action.type]) {
const newState = _.extend({}, state);
return userListHandlers[action.type](newState, action);
} else {
return state;
},
SEARCH_TERM_TOO_SHORT(state, action) {
return {
...state,
errors: {
...state.errors,
search_term: action.errors.termTooShort
}
}
};
const tabListHandlers = {
SELECT_TAB: (state, action) => {
state.selected = action.payload.tabIndex;
return state;
},
LOADING_USERS(state, _action) {
return {
...state,
isLoading: true
}
};
}
}
const tabList = (state = initialState, action) => {
if (tabListHandlers[action.type]) {
const newState = _.extend({}, state);
return tabListHandlers[action.type](newState, action);
} else {
return state;
const tabListHandlers = {
SELECT_TAB(state, action) {
const {selected, queryParams} = action.payload
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({
userList,
tabList
});
userList: makeReducer(userListHandlers),
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 {parse} from 'qs'
import TabActions from './actions/TabActions'
export default {
start(store) {
const tabList = store.getState().tabList
const router = {
start: (store) => {
const tabList = store.getState().tabList;
page.base(tabList.basePath)
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(tab.path, (ctx) => {
store.dispatch(TabActions.selectTab( i ));
});
});
if (tabList.tabs.length)
page('/', tabList.tabs[0].path);
page.start();
}
};
export default router
page.start()
}
}

View File

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

View File

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

View File

@ -16,6 +16,7 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import $ from 'jquery'
import React from 'react'
import ReactDOM from 'react-dom'
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 initialState from 'jsx/account_course_user_search/store/initialState'
if (location.pathname.indexOf(ENV.BASE_PATH) === -1) {
location.replace(ENV.BASE_PATH)
} else {
initialState.tabList.basePath = ENV.BASE_PATH
// eg: '/accounts/xxx' for anything like '/accounts/xxx/whatever`
initialState.tabList.basePath = window.location.pathname.match(/.*accounts\/[^/]*/)[0]
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
// still using the old store model. That is why this might seem kind of weird.
const store = configureStore(initialState)
const props = {
permissions: ENV.PERMISSIONS,
accountId: ENV.ACCOUNT_ID.toString(),
roles: Array.prototype.slice.call(ENV.COURSE_ROLES),
addUserUrls: ENV.URLS,
store
}
const options = {
permissions: ENV.PERMISSIONS,
accountId: ENV.ACCOUNT_ID.toString(),
roles: Array.prototype.slice.call(ENV.COURSE_ROLES),
addUserUrls: ENV.URLS,
store
}
store.subscribe(() => {
ReactDOM.render(<App {...options} />, content)
// this is where we take care of the 3 things we need to do outside of the
// happy React/redux declarative/vDOM blessed path. It's so when we click
// either the "Courses" or "People" tabs on the left, it highlights the right
// tab and updates the crumb and document title
const originalDocumentTitle = document.title
function updateDocumentTitleBreadcrumbAndActiveTab(activeTab) {
// give the correct left nav item an active class
$('#section-tabs .section a').each(function() {
const $tab = $(this)
$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 => {
let keep = true;
if (item.permissions && item.permissions.length) {
keep = item.permissions.reduce((prevPerm, curPerm) => {
return prevPerm && perms[curPerm];
}, keep);
}
return keep;
});
};
export default permissionFilter
/**
* filters `items` to just the ones who don't require any unavailable permissions.
*/
export default (items, availablePermissions) => {
const permissionIsAvailable = p => availablePermissions[p]
return items.filter(item => (item.permissions || []).every(permissionIsAvailable))
}

View File

@ -1373,13 +1373,7 @@ class Account < ActiveRecord::Base
manage_settings = user && self.grants_right?(user, :manage_account_settings)
if root_account.site_admin?
tabs = []
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_USERS, :label => t("People"), :css_class => 'users', :href => :account_users_path } if user && self.grants_right?(user, :read_roster)
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_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)
else
tabs = []
if feature_enabled?(:course_user_search)
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))
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_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("People"), :css_class => 'users', :href => :account_users_path } if user && self.grants_right?(user, :read_roster)
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)
if user && self.grants_right?(user, :manage_outcomes)

View File

@ -41,36 +41,6 @@
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 {
margin-right: 10px;
&: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
# with this program. If not, see <http://www.gnu.org/licenses/>.
%>
<%
@active_tab = "settings"
add_crumb t(:settings_crumb, "Settings")
js_bundle :account_settings
@ -33,10 +30,7 @@
}
end
%>
<% content_for :right_side do %>
<%= render :partial => "courses_right_side" unless @account.site_admin? %>
<%= render :partial => "additional_settings_right_side" %>
<% end %>
<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
# 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>
<div id="sub_account_urls">
<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/rubrics' => 'search#rubrics'
get 'search/all_courses' => 'search#all_courses'
resources :users, except: :destroy do
resources :users, except: [:destroy, :index] do
match 'masquerade', via: [:get, :post]
concerns :files, :file_images

View File

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

View File

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

View File

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

View File

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

View File

@ -16,281 +16,234 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
define([
'jsx/account_course_user_search/actions/UserActions',
'jsx/account_course_user_search/UsersStore'
], (actions, UserStore) => {
import actions from 'jsx/account_course_user_search/actions/UserActions'
import UserStore from 'jsx/account_course_user_search/UsersStore'
const STUDENTS = [
{
"id": "46",
"name": "Irene Adler",
"sortable_name": "Adler, Irene",
"short_name": "Irene Adler",
"sis_user_id": "957",
"integration_id": null,
"sis_login_id": "student57",
"sis_import_id": "1",
"login_id": "student57",
"email": "brotherhood@example.com",
"last_login": null,
"time_zone": "Mountain Time (US & Canada)"
},
{
"id": "44",
"name": "Saint-John Allerdyce",
"sortable_name": "Allerdyce, Saint-John",
"short_name": "Saint-John Allerdyce",
"sis_user_id": "955",
"integration_id": null,
"sis_login_id": "student55",
"sis_import_id": "1",
"login_id": "student55",
"email": "brotherhood@example.com",
"last_login": null,
"time_zone": "Mountain Time (US & Canada)"
},
{
"id": "52",
"name": "Michael Baer",
"sortable_name": "Baer, Michael",
"short_name": "Michael Baer",
"sis_user_id": "963",
"integration_id": null,
"sis_login_id": "student63",
"sis_import_id": "1",
"login_id": "student63",
"email": "marauders@example.com",
"last_login": null,
"time_zone": "Mountain Time (US & Canada)"
}];
const STUDENTS = [
{
id: '46',
name: 'Irene Adler',
sortable_name: 'Adler, Irene',
short_name: 'Irene Adler',
sis_user_id: '957',
integration_id: null,
sis_login_id: 'student57',
sis_import_id: '1',
login_id: 'student57',
email: 'brotherhood@example.com',
last_login: null,
time_zone: 'Mountain Time (US & Canada)'
},
{
id: '44',
name: 'Saint-John Allerdyce',
sortable_name: 'Allerdyce, Saint-John',
short_name: 'Saint-John Allerdyce',
sis_user_id: '955',
integration_id: null,
sis_login_id: 'student55',
sis_import_id: '1',
login_id: 'student55',
email: 'brotherhood@example.com',
last_login: null,
time_zone: 'Mountain Time (US & Canada)'
},
{
id: '52',
name: 'Michael Baer',
sortable_name: 'Baer, Michael',
short_name: 'Michael Baer',
sis_user_id: '963',
integration_id: null,
sis_login_id: 'student63',
sis_import_id: '1',
login_id: 'student63',
email: 'marauders@example.com',
last_login: null,
time_zone: 'Mountain Time (US & Canada)'
}
]
QUnit.module('Account Course User Search Actions');
QUnit.module('Account Course User Search Actions')
asyncTest('apiCreateUser', () => {
const server = sinon.fakeServer.create();
UserStore.reset({accountId: 1});
test('apiCreateUser', function(assert) {
assert.expect(3)
const server = sinon.fakeServer.create()
UserStore.reset({accountId: 1})
server.respondWith('POST', '/api/v1/accounts/1/users',
[200, { "Content-Type": "application/json" }, JSON.stringify(STUDENTS[0])]
);
server.respondWith('POST', '/api/v1/accounts/1/users', [
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) => {
equal(response.type, 'ADD_TO_USERS', 'it dispatches the proper action');
equal(Array.isArray(response.payload.users), true, 'it returns a users array');
start();
});
actions.apiCreateUser(1, {})(response => {
equal(response.type, 'ADD_TO_USERS', 'it dispatches the proper action')
equal(Array.isArray(response.payload.users), true, 'it returns a users array')
})
server.respond();
server.restore();
});
server.respond()
server.restore()
})
test('addError', () => {
const message = actions.addError({errorKey: 'error'});
equal(message.type, 'ADD_ERROR', 'it returns the proper action type');
deepEqual(message.error, {errorKey: 'error'}, 'it returns the proper error');
});
test('addError', () => {
const message = actions.addError({errorKey: 'error'})
equal(message.type, 'ADD_ERROR', 'it returns the proper action type')
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.
let counter = 2;
function done () {
--counter || start();
// This is a POST rather than a PUT because of the way our $.getJSON converts
// 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 server = sinon.fakeServer.create();
UserStore.reset({accountId: 1});
const fakeGetState = () => ({
userList: {
searchFilter: 'abc',
next: '/api/v1/accounts/1/users?page=2'
}
})
server.respondWith('GET', /api\/v1\/accounts\/1\/users/,
[200, { "Content-Type": "application/json" }, JSON.stringify(STUDENTS)]
);
const fakeUserStore = {
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(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: []
}
}
});
equal(typeof actionThunk, 'function', 'it initally returns a callback function')
actions.apiGetUsers()((response) => {
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
}
}
});
actionThunk(fakeDispatcher, fakeGetState)
})
server.respond();
server.restore();
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()
}
}
asyncTest('apiUpdateUser', () => {
const server = sinon.fakeServer.create();
// This is a POST rather than a PUT because of the way our $.getJSON converts
// 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 fakeGetStateSearchLengthOkay = () => ({
userList: {
searchFilter: {
search_term: 'abcd'
}
}
})
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');
actionThunk(fakeDispatcher, fakeGetState);
});
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 fakeGetStateSearchLengthTooShort = () => ({
userList: {
searchFilter: {
search_term: 'a'
}
}
})
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);
actionThunk(fakeDispatcherSearchLengthTooShort, fakeGetStateSearchLengthTooShort);
});
equal(typeof actionThunk, 'function', 'it initally returns a callback function')
});
actionThunk(fakeDispatcherSearchLengthOkay, fakeGetStateSearchLengthOkay)
actionThunk(fakeDispatcherSearchLengthTooShort, fakeGetStateSearchLengthTooShort)
})

View File

@ -327,7 +327,7 @@ define(['jsx/account_course_user_search/reducers/rootReducer'], (reducer) => {
const action = {
type: 'SELECT_TAB',
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
get "/accounts/#{@account.id}"
expect(f(".react-tabs > ul")).to_not include_text("Courses")
expect(f("#left-side #section-tabs")).not_to include_text("Courses")
end
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]')
end
def click_tab
ff(".react-tabs > ul li").detect{|tab| tab.text.include?("People")}.click
wait_for_ajaximations
it "should be able to toggle between 'People' and 'Courses' tabs" do
user_with_pseudonym(:account => @account, :name => "Test User")
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
it "should not show the people tab without permission" do
@account.role_overrides.create! :role => admin_role, :permission => 'read_roster', :enabled => false
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
it "should not show the create users button for non-root acocunts" do
sub_account = Account.create!(:name => "sub", :parent_account => @account)
get "/accounts/#{sub_account.id}"
click_tab
get "/accounts/#{sub_account.id}/users"
expect(f("#content")).not_to contain_css('button.add_user')
end
it "should be able to create users" do
get "/accounts/#{@account.id}"
click_tab
get "/accounts/#{@account.id}/users"
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}")
end
get "/accounts/#{@account.id}"
click_tab
get "/accounts/#{@account.id}/users"
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")
user_with_pseudonym(:account => @account, :name => "diffrient user")
get "/accounts/#{@account.id}"
click_tab
get "/accounts/#{@account.id}/users"
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")
user_with_pseudonym(:account => @account, :name => "diffrient user")
get "/accounts/#{@account.id}"
click_tab
get "/accounts/#{@account.id}/users"
f('#peopleOptionsBtn').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")
user_with_pseudonym(:account => @account, :name => "diffrient user")
get "/accounts/#{@account.id}"
click_tab
get "/accounts/#{@account.id}/users"
f('#peopleOptionsBtn').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
user = account_admin_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"
wait_for_ajaximations
f('li.user a').click
@ -134,6 +136,13 @@ describe "conversations new" do
f('.icon-email').click
wait_for_ajaximations
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
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"
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:
version "1.0.1"
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"
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:
version "0.6.0"
resolved "https://registry.yarnpkg.com/react-tinymce/-/react-tinymce-0.6.0.tgz#729cd1edd00c4214b7d253f02283a7d2b52813aa"