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:
parent
c004ff0367
commit
3c30c9b6df
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
|
|
|
@ -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
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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'
|
||||
}
|
||||
]
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
%>
|
|
@ -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>
|
||||
|
|
|
@ -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;"> </a>
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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: '/',
|
||||
|
|
|
@ -48,6 +48,8 @@ test('handleUpdateSearchFilter dispatches applySearchFilter action', (assert) =>
|
|||
<UsersPane
|
||||
store={fakeStore}
|
||||
roles={['a']}
|
||||
queryParams={{}}
|
||||
onUpdateQueryParams={function(){}}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
|
|
|
@ -327,7 +327,7 @@ define(['jsx/account_course_user_search/reducers/rootReducer'], (reducer) => {
|
|||
const action = {
|
||||
type: 'SELECT_TAB',
|
||||
payload: {
|
||||
tabIndex: 1
|
||||
selected: 1
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
11
yarn.lock
11
yarn.lock
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue