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
|
||||||
end
|
end
|
||||||
|
|
||||||
def course_user_search
|
|
||||||
return unless authorized_action(@account, @current_user, :read)
|
|
||||||
can_read_course_list = !@account.site_admin? && @account.grants_right?(@current_user, session, :read_course_list)
|
|
||||||
can_read_roster = @account.grants_right?(@current_user, session, :read_roster)
|
|
||||||
can_manage_account = @account.grants_right?(@current_user, session, :manage_account_settings)
|
|
||||||
|
|
||||||
unless can_read_course_list || can_read_roster
|
|
||||||
return render_unauthorized_action
|
|
||||||
end
|
|
||||||
|
|
||||||
@permissions = {
|
|
||||||
theme_editor: can_manage_account && @account.branding_allowed?,
|
|
||||||
can_read_course_list: can_read_course_list,
|
|
||||||
can_read_roster: can_read_roster,
|
|
||||||
can_create_courses: @account.grants_right?(@current_user, session, :manage_courses),
|
|
||||||
can_create_users: @account.root_account? && @account.grants_right?(@current_user, session, :manage_user_logins),
|
|
||||||
analytics: @account.service_enabled?(:analytics),
|
|
||||||
can_masquerade: @account.grants_right?(@current_user, session, :become_user),
|
|
||||||
can_message_users: @account.grants_right?(@current_user, session, :send_messages),
|
|
||||||
can_edit_users: @account.grants_any_right?(@current_user, session, :manage_students, :manage_user_logins)
|
|
||||||
}
|
|
||||||
|
|
||||||
js_env({
|
|
||||||
TIMEZONES: {
|
|
||||||
priority_zones: localized_timezones(I18nTimeZone.us_zones),
|
|
||||||
timezones: localized_timezones(I18nTimeZone.all)
|
|
||||||
},
|
|
||||||
BASE_PATH: request.env['PATH_INFO'].sub(/\/search.*/, '') + '/search',
|
|
||||||
COURSE_ROLES: Role.course_role_data_for_account(@account, @current_user),
|
|
||||||
URLS: {
|
|
||||||
USER_LISTS_URL: course_user_lists_url("{{ id }}"),
|
|
||||||
ENROLL_USERS_URL: course_enroll_users_url("{{ id }}", :format => :json)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
render template: "accounts/course_user_search"
|
|
||||||
end
|
|
||||||
|
|
||||||
# @API Get the sub-accounts of an account
|
# @API Get the sub-accounts of an account
|
||||||
#
|
#
|
||||||
# List accounts that are sub-accounts of the given account.
|
# List accounts that are sub-accounts of the given account.
|
||||||
|
@ -1169,16 +1132,6 @@ class AccountsController < ApplicationController
|
||||||
rce_js_env(:basic)
|
rce_js_env(:basic)
|
||||||
end
|
end
|
||||||
|
|
||||||
def localized_timezones(timezones)
|
|
||||||
timezones.map do |timezone|
|
|
||||||
{
|
|
||||||
name: timezone.name,
|
|
||||||
localized_name: timezone.to_s
|
|
||||||
}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
private :localized_timezones
|
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def ensure_sis_max_name_length_value!(account_settings)
|
def ensure_sis_max_name_length_value!(account_settings)
|
||||||
|
|
|
@ -2279,4 +2279,57 @@ class ApplicationController < ActionController::Base
|
||||||
def teardown_live_events_context
|
def teardown_live_events_context
|
||||||
LiveEvents.clear_context!
|
LiveEvents.clear_context!
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# TODO: this belongs in AccountsController but while :course_user_search is still behind a feature flag we
|
||||||
|
# have to let UsersController::index own the /accounts/x/users route so it responds as it used to if the
|
||||||
|
# feature isn't enabled but `return course_user_search` if the feature is enabled. you can't `return` an
|
||||||
|
# action from another controller but you can from a controller you inherit from. Hence why this can be
|
||||||
|
# here in ApplicationController but not AccountsController for now. Once we remove the feature flag,
|
||||||
|
# we should move this back to AccountsController and just change conf/routes.rb to let
|
||||||
|
# AccountsController::users own /accounts/x/users instead UsersController::index
|
||||||
|
def course_user_search
|
||||||
|
return unless authorized_action(@account, @current_user, :read)
|
||||||
|
can_read_course_list = @account.grants_right?(@current_user, session, :read_course_list)
|
||||||
|
can_read_roster = @account.grants_right?(@current_user, session, :read_roster)
|
||||||
|
can_manage_account = @account.grants_right?(@current_user, session, :manage_account_settings)
|
||||||
|
|
||||||
|
unless can_read_course_list || can_read_roster
|
||||||
|
return render_unauthorized_action
|
||||||
|
end
|
||||||
|
|
||||||
|
def localized_timezones(zones)
|
||||||
|
zones.map { |tz| {name: tz.name, localized_name: tz.to_s} }
|
||||||
|
end
|
||||||
|
|
||||||
|
js_env({
|
||||||
|
TIMEZONES: {
|
||||||
|
priority_zones: localized_timezones(I18nTimeZone.us_zones),
|
||||||
|
timezones: localized_timezones(I18nTimeZone.all)
|
||||||
|
},
|
||||||
|
COURSE_ROLES: Role.course_role_data_for_account(@account, @current_user),
|
||||||
|
URLS: {
|
||||||
|
USER_LISTS_URL: course_user_lists_url("{{ id }}"),
|
||||||
|
ENROLL_USERS_URL: course_enroll_users_url("{{ id }}", :format => :json)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
js_bundle :account_course_user_search
|
||||||
|
css_bundle :account_course_user_search
|
||||||
|
@page_title = @account.name
|
||||||
|
add_crumb '', '?' # the text for this will be set by javascript
|
||||||
|
js_env({
|
||||||
|
ACCOUNT_ID: @account.id,
|
||||||
|
PERMISSIONS: {
|
||||||
|
can_read_course_list: can_read_course_list,
|
||||||
|
can_read_roster: can_read_roster,
|
||||||
|
can_create_courses: @account.grants_right?(@current_user, session, :manage_courses),
|
||||||
|
can_create_users: @account.root_account? && @account.grants_right?(@current_user, session, :manage_user_logins),
|
||||||
|
analytics: @account.service_enabled?(:analytics),
|
||||||
|
can_masquerade: @account.grants_right?(@current_user, session, :become_user),
|
||||||
|
can_message_users: @account.grants_right?(@current_user, session, :send_messages),
|
||||||
|
can_edit_users: @account.grants_any_right?(@current_user, session, :manage_students, :manage_user_logins)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
render html: '', layout: true
|
||||||
|
end
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|
|
@ -390,6 +390,11 @@ class UsersController < ApplicationController
|
||||||
# @returns [User]
|
# @returns [User]
|
||||||
def index
|
def index
|
||||||
get_context
|
get_context
|
||||||
|
if !api_request? && @context.feature_enabled?(:course_user_search)
|
||||||
|
@account ||= @context
|
||||||
|
return course_user_search
|
||||||
|
end
|
||||||
|
|
||||||
if authorized_action(@context, @current_user, :read_roster)
|
if authorized_action(@context, @current_user, :read_roster)
|
||||||
@root_account = @context.root_account
|
@root_account = @context.root_account
|
||||||
@query = (params[:user] && params[:user][:name]) || params[:term]
|
@query = (params[:user] && params[:user][:name]) || params[:term]
|
||||||
|
|
|
@ -150,7 +150,7 @@ class CoursesList extends React.Component {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="courses-list" role="rowgroup">
|
<div className="courses-list" role="rowgroup">
|
||||||
{courses.map((course) => {
|
{(courses || []).map((course) => {
|
||||||
const urlsForCourse = {
|
const urlsForCourse = {
|
||||||
USER_LISTS_URL: $.replaceTags(this.props.addUserUrls.USER_LISTS_URL, 'id', course.id),
|
USER_LISTS_URL: $.replaceTags(this.props.addUserUrls.USER_LISTS_URL, 'id', course.id),
|
||||||
ENROLL_USERS_URL: $.replaceTags(this.props.addUserUrls.ENROLL_USERS_URL, 'id', course.id)
|
ENROLL_USERS_URL: $.replaceTags(this.props.addUserUrls.ENROLL_USERS_URL, 'id', course.id)
|
||||||
|
|
|
@ -17,19 +17,27 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import PropTypes from 'prop-types'
|
import {shape, arrayOf, string, func} from 'prop-types'
|
||||||
import { debounce } from 'underscore'
|
import {debounce} from 'underscore'
|
||||||
import I18n from 'i18n!account_course_user_search'
|
import I18n from 'i18n!account_course_user_search'
|
||||||
import CoursesStore from './CoursesStore'
|
import CoursesStore from './CoursesStore'
|
||||||
import TermsStore from './TermsStore'
|
import TermsStore from './TermsStore'
|
||||||
import AccountsTreeStore from './AccountsTreeStore'
|
import AccountsTreeStore from './AccountsTreeStore'
|
||||||
import CoursesList from './CoursesList'
|
import CoursesList from './CoursesList'
|
||||||
import CoursesToolbar from './CoursesToolbar'
|
import CoursesToolbar from './CoursesToolbar'
|
||||||
import renderSearchMessage from './renderSearchMessage'
|
import SearchMessage from './SearchMessage'
|
||||||
|
|
||||||
const MIN_SEARCH_LENGTH = 3
|
const MIN_SEARCH_LENGTH = 3
|
||||||
const stores = [CoursesStore, TermsStore, AccountsTreeStore]
|
const stores = [CoursesStore, TermsStore, AccountsTreeStore]
|
||||||
const { shape, arrayOf, string } = PropTypes
|
|
||||||
|
const defaultFilters = {
|
||||||
|
enrollment_term_id: '',
|
||||||
|
search_term: '',
|
||||||
|
with_students: false,
|
||||||
|
sort: 'sis_course_id',
|
||||||
|
order: 'asc',
|
||||||
|
search_by: 'course'
|
||||||
|
}
|
||||||
|
|
||||||
class CoursesPane extends React.Component {
|
class CoursesPane extends React.Component {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
|
@ -38,26 +46,22 @@ class CoursesPane extends React.Component {
|
||||||
USER_LISTS_URL: string.isRequired,
|
USER_LISTS_URL: string.isRequired,
|
||||||
ENROLL_USERS_URL: string.isRequired,
|
ENROLL_USERS_URL: string.isRequired,
|
||||||
}).isRequired,
|
}).isRequired,
|
||||||
accountId: string.isRequired,
|
queryParams: shape().isRequired,
|
||||||
|
onUpdateQueryParams: func.isRequired,
|
||||||
|
accountId: string.isRequired
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor () {
|
constructor () {
|
||||||
super()
|
super()
|
||||||
|
|
||||||
const filters = {
|
|
||||||
enrollment_term_id: '',
|
|
||||||
search_term: '',
|
|
||||||
with_students: false,
|
|
||||||
sort: 'sis_course_id',
|
|
||||||
order: 'asc',
|
|
||||||
search_by: 'course',
|
|
||||||
}
|
|
||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
filters,
|
filters: defaultFilters,
|
||||||
draftFilters: filters,
|
draftFilters: defaultFilters,
|
||||||
errors: {},
|
errors: {},
|
||||||
previousCourses: {data: []},
|
previousCourses: {
|
||||||
|
data: [],
|
||||||
|
loading: true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Doing this here because the class property version didn't work :(
|
// Doing this here because the class property version didn't work :(
|
||||||
|
@ -66,6 +70,8 @@ class CoursesPane extends React.Component {
|
||||||
|
|
||||||
componentWillMount () {
|
componentWillMount () {
|
||||||
stores.forEach(s => s.addChangeListener(this.refresh))
|
stores.forEach(s => s.addChangeListener(this.refresh))
|
||||||
|
const filters = Object.assign({}, defaultFilters, this.props.queryParams)
|
||||||
|
this.setState({filters, draftFilters: filters})
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount () {
|
componentDidMount () {
|
||||||
|
@ -78,7 +84,13 @@ class CoursesPane extends React.Component {
|
||||||
stores.forEach(s => s.removeChangeListener(this.refresh))
|
stores.forEach(s => s.removeChangeListener(this.refresh))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
componentWillReceiveProps(nextProps) {
|
||||||
|
const filters = Object.assign({}, defaultFilters, nextProps.queryParams)
|
||||||
|
this.setState({filters, draftFilters: filters})
|
||||||
|
}
|
||||||
|
|
||||||
fetchCourses = () => {
|
fetchCourses = () => {
|
||||||
|
this.updateQueryString()
|
||||||
CoursesStore.load(this.state.filters)
|
CoursesStore.load(this.state.filters)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -121,6 +133,17 @@ class CoursesPane extends React.Component {
|
||||||
this.forceUpdate()
|
this.forceUpdate()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateQueryString = () => {
|
||||||
|
const differences = Object.keys(this.state.filters).reduce((memo, key) => {
|
||||||
|
const value = this.state.filters[key]
|
||||||
|
if (value !== defaultFilters[key]) {
|
||||||
|
return {...memo, [key]: value}
|
||||||
|
}
|
||||||
|
return memo
|
||||||
|
}, {})
|
||||||
|
this.props.onUpdateQueryParams(differences)
|
||||||
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { filters, draftFilters, errors } = this.state
|
const { filters, draftFilters, errors } = this.state
|
||||||
let courses = CoursesStore.get(filters)
|
let courses = CoursesStore.get(filters)
|
||||||
|
@ -139,8 +162,8 @@ class CoursesPane extends React.Component {
|
||||||
terms={terms && terms.data}
|
terms={terms && terms.data}
|
||||||
accounts={accounts}
|
accounts={accounts}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
{...draftFilters}
|
|
||||||
errors={errors}
|
errors={errors}
|
||||||
|
draftFilters={draftFilters}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CoursesList
|
<CoursesList
|
||||||
|
@ -153,7 +176,11 @@ class CoursesPane extends React.Component {
|
||||||
order={filters.order}
|
order={filters.order}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{renderSearchMessage(courses, this.fetchMoreCourses, I18n.t('No courses found'))}
|
<SearchMessage
|
||||||
|
collection={courses}
|
||||||
|
loadMore={this.fetchMoreCourses}
|
||||||
|
noneFoundMessage={I18n.t('No courses found')}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,8 +17,9 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import PropTypes from 'prop-types'
|
import { string, bool, func, arrayOf, shape, oneOf } from 'prop-types'
|
||||||
import I18n from 'i18n!account_course_user_search'
|
import I18n from 'i18n!account_course_user_search'
|
||||||
|
import preventDefault from 'compiled/fn/preventDefault'
|
||||||
import TermsStore from './TermsStore'
|
import TermsStore from './TermsStore'
|
||||||
import AccountsTreeStore from './AccountsTreeStore'
|
import AccountsTreeStore from './AccountsTreeStore'
|
||||||
import NewCourseModal from './NewCourseModal'
|
import NewCourseModal from './NewCourseModal'
|
||||||
|
@ -26,17 +27,35 @@ import IcInput from './IcInput'
|
||||||
import IcSelect from './IcSelect'
|
import IcSelect from './IcSelect'
|
||||||
import IcCheckbox from './IcCheckbox'
|
import IcCheckbox from './IcCheckbox'
|
||||||
|
|
||||||
const { string, bool, func, arrayOf, shape } = PropTypes
|
const TermOpts = ({terms}) => {
|
||||||
|
return terms ? (
|
||||||
|
<optgroup label={I18n.t('Show courses from')}>
|
||||||
|
<option key="all" value="">
|
||||||
|
{I18n.t('All Terms')}
|
||||||
|
</option>
|
||||||
|
{terms.map(term =>
|
||||||
|
<option key={term.id} value={term.id}>
|
||||||
|
{term.name}
|
||||||
|
</option>
|
||||||
|
)}
|
||||||
|
</optgroup>
|
||||||
|
) : (
|
||||||
|
<option value="">{I18n.t('Loading...')}</option>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
TermOpts.propTypes = { terms: arrayOf(TermsStore.PropType) }
|
||||||
|
|
||||||
class CoursesToolbar extends React.Component {
|
export default class CoursesToolbar extends React.Component {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
onUpdateFilters: func.isRequired,
|
onUpdateFilters: func.isRequired,
|
||||||
onApplyFilters: func.isRequired,
|
onApplyFilters: func.isRequired,
|
||||||
isLoading: bool,
|
isLoading: bool.isRequired,
|
||||||
with_students: bool.isRequired,
|
draftFilters: shape({
|
||||||
search_term: string,
|
with_students: bool.isRequired,
|
||||||
enrollment_term_id: string,
|
search_by: oneOf(['course', 'teacher']).isRequired,
|
||||||
sortColumn: string,
|
search_term: string.isRequired,
|
||||||
|
enrollment_term_id: string.isRequired,
|
||||||
|
}).isRequired,
|
||||||
errors: shape({ search_term: string }).isRequired,
|
errors: shape({ search_term: string }).isRequired,
|
||||||
terms: arrayOf(TermsStore.PropType),
|
terms: arrayOf(TermsStore.PropType),
|
||||||
accounts: arrayOf(AccountsTreeStore.PropType),
|
accounts: arrayOf(AccountsTreeStore.PropType),
|
||||||
|
@ -45,42 +64,15 @@ class CoursesToolbar extends React.Component {
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
terms: null,
|
terms: null,
|
||||||
accounts: [],
|
accounts: [],
|
||||||
search_term: '',
|
|
||||||
enrollment_term_id: null,
|
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
sortColumn: ''
|
|
||||||
}
|
|
||||||
|
|
||||||
applyFilters = (e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
this.props.onApplyFilters()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
addCourse = () => {
|
addCourse = () => {
|
||||||
this.addCourseModal.openModal()
|
this.addCourseModal.openModal()
|
||||||
}
|
}
|
||||||
|
|
||||||
renderTerms () {
|
|
||||||
const { terms } = this.props
|
|
||||||
|
|
||||||
if (terms) {
|
|
||||||
return [
|
|
||||||
<option key="all" value="">
|
|
||||||
{I18n.t('All Terms')}
|
|
||||||
</option>
|
|
||||||
].concat(terms.map(term => (
|
|
||||||
<option key={term.id} value={term.id}>
|
|
||||||
{term.name}
|
|
||||||
</option>
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
|
|
||||||
return <option value="">{I18n.t('Loading...')}</option>
|
|
||||||
}
|
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { terms, accounts, onUpdateFilters, isLoading, errors, ...props } = this.props
|
const { terms, accounts, onUpdateFilters, isLoading, errors, draftFilters} = this.props
|
||||||
|
|
||||||
|
|
||||||
const addCourseButton = window.ENV.PERMISSIONS.can_create_courses ?
|
const addCourseButton = window.ENV.PERMISSIONS.can_create_courses ?
|
||||||
(<div>
|
(<div>
|
||||||
|
@ -96,31 +88,36 @@ class CoursesToolbar extends React.Component {
|
||||||
<form
|
<form
|
||||||
className="course_search_bar"
|
className="course_search_bar"
|
||||||
style={{opacity: isLoading ? 0.5 : 1}}
|
style={{opacity: isLoading ? 0.5 : 1}}
|
||||||
onSubmit={this.applyFilters}
|
onSubmit={preventDefault(this.props.onApplyFilters)}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
<div className="ic-Form-action-box courses-list-search-bar-layout">
|
<div className="ic-Form-action-box courses-list-search-bar-layout">
|
||||||
<div className="ic-Form-action-box__Form">
|
<div className="ic-Form-action-box__Form">
|
||||||
<IcSelect
|
<IcSelect
|
||||||
value={props.enrollment_term_id}
|
value={draftFilters.enrollment_term_id}
|
||||||
onChange={e => onUpdateFilters({enrollment_term_id: e.target.value})}
|
onChange={e => onUpdateFilters({enrollment_term_id: e.target.value})}
|
||||||
>
|
>
|
||||||
{this.renderTerms()}
|
<TermOpts terms={terms} />
|
||||||
</IcSelect>
|
</IcSelect>
|
||||||
<IcSelect
|
<IcSelect
|
||||||
value={props.search_by}
|
value={draftFilters.search_by}
|
||||||
onChange={e => onUpdateFilters({search_by: e.target.value})}
|
onChange={e => onUpdateFilters({search_by: e.target.value})}
|
||||||
>
|
>
|
||||||
<option key="course" value="course">
|
<optgroup label={I18n.t('Search By')}>
|
||||||
{I18n.t('Course')}
|
<option key="course" value="course">
|
||||||
</option>
|
{I18n.t('Course')}
|
||||||
<option key="teacher" value="teacher">
|
</option>
|
||||||
{I18n.t('Teacher')}
|
<option key="teacher" value="teacher">
|
||||||
</option>
|
{I18n.t('Teacher')}
|
||||||
|
</option>
|
||||||
|
</optgroup>
|
||||||
</IcSelect>
|
</IcSelect>
|
||||||
<IcInput
|
<IcInput
|
||||||
value={props.search_term}
|
value={draftFilters.search_term}
|
||||||
placeholder={I18n.t('Search courses...')}
|
placeholder={draftFilters.search_by === 'teacher' ?
|
||||||
|
I18n.t('Search courses by teacher...') :
|
||||||
|
I18n.t('Search courses...')
|
||||||
|
}
|
||||||
onChange={e => onUpdateFilters({search_term: e.target.value})}
|
onChange={e => onUpdateFilters({search_term: e.target.value})}
|
||||||
error={errors.search_term}
|
error={errors.search_term}
|
||||||
type="search"
|
type="search"
|
||||||
|
@ -131,7 +128,7 @@ class CoursesToolbar extends React.Component {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<IcCheckbox
|
<IcCheckbox
|
||||||
checked={props.with_students}
|
checked={draftFilters.with_students}
|
||||||
onChange={e => onUpdateFilters({with_students: e.target.checked})}
|
onChange={e => onUpdateFilters({with_students: e.target.checked})}
|
||||||
label={I18n.t('Hide courses without enrollments')}
|
label={I18n.t('Hide courses without enrollments')}
|
||||||
/>
|
/>
|
||||||
|
@ -146,4 +143,3 @@ class CoursesToolbar extends React.Component {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default CoursesToolbar
|
|
||||||
|
|
|
@ -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 React from 'react'
|
||||||
import PropTypes from 'prop-types'
|
import {string} from 'prop-types'
|
||||||
import I18n from 'i18n!account_course_user_search'
|
|
||||||
|
|
||||||
var UserLink = React.createClass({
|
export default function UserLink ({ id, display_name, avatar_image_url }) {
|
||||||
propTypes: {
|
const url = `/users/${id}`
|
||||||
id: PropTypes.string.isRequired,
|
return (
|
||||||
display_name: PropTypes.string.isRequired,
|
<div className="ellipsis">
|
||||||
avatar_image_url: PropTypes.string
|
{!!avatar_image_url &&
|
||||||
},
|
<span className="ic-avatar UserLink__Avatar">
|
||||||
|
<img src={avatar_image_url} alt='' />
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
<a href={url} className="user_link">{display_name}</a>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
UserLink.propTypes = {
|
||||||
var { id, display_name, avatar_image_url } = this.props;
|
id: string.isRequired,
|
||||||
var url = `/users/${id}`;
|
display_name: string.isRequired,
|
||||||
return (
|
avatar_image_url: string
|
||||||
<div className="ellipsis">
|
}
|
||||||
{!!avatar_image_url &&
|
|
||||||
<span className="ic-avatar UserLink__Avatar">
|
|
||||||
<img src={avatar_image_url} alt={`User avatar for ${display_name}`} />
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
<a href={url} className="user_link">{display_name}</a>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export default UserLink
|
|
||||||
|
|
|
@ -17,25 +17,30 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import PropTypes from 'prop-types'
|
import {shape, func, arrayOf, string } from 'prop-types'
|
||||||
import I18n from 'i18n!account_course_user_search'
|
import I18n from 'i18n!account_course_user_search'
|
||||||
import _ from 'underscore'
|
import _ from 'underscore'
|
||||||
import UsersStore from './UsersStore'
|
import UsersStore from './UsersStore'
|
||||||
import UsersList from './UsersList'
|
import UsersList from './UsersList'
|
||||||
import UsersToolbar from './UsersToolbar'
|
import UsersToolbar from './UsersToolbar'
|
||||||
import renderSearchMessage from './renderSearchMessage'
|
import SearchMessage from './SearchMessage'
|
||||||
import UserActions from './actions/UserActions'
|
import UserActions from './actions/UserActions'
|
||||||
|
|
||||||
const MIN_SEARCH_LENGTH = 3;
|
const MIN_SEARCH_LENGTH = 3;
|
||||||
|
|
||||||
class UsersPane extends React.Component {
|
export default class UsersPane extends React.Component {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
store: PropTypes.shape({
|
store: shape({
|
||||||
getState: PropTypes.func.isRequired,
|
getState: func.isRequired,
|
||||||
dispatch: PropTypes.func.isRequired,
|
dispatch: func.isRequired,
|
||||||
subscribe: PropTypes.func.isRequired,
|
subscribe: func.isRequired,
|
||||||
}).isRequired,
|
}).isRequired,
|
||||||
roles: PropTypes.arrayOf(PropTypes.string).isRequired,
|
roles: arrayOf(string).isRequired,
|
||||||
|
onUpdateQueryParams: func.isRequired,
|
||||||
|
queryParams: shape({
|
||||||
|
search_term: string,
|
||||||
|
role_filter_id: string
|
||||||
|
}).isRequired
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor (props) {
|
constructor (props) {
|
||||||
|
@ -48,7 +53,12 @@ class UsersPane extends React.Component {
|
||||||
|
|
||||||
componentDidMount = () => {
|
componentDidMount = () => {
|
||||||
this.unsubscribe = this.props.store.subscribe(this.handleStateChange);
|
this.unsubscribe = this.props.store.subscribe(this.handleStateChange);
|
||||||
this.props.store.dispatch(UserActions.apiGetUsers());
|
|
||||||
|
// make page reflect what the querystring params asked for
|
||||||
|
const {search_term, role_filter_id} = {...UsersToolbar.defaultProps, ...this.props.queryParams}
|
||||||
|
this.props.store.dispatch(UserActions.updateSearchFilter({search_term, role_filter_id}))
|
||||||
|
|
||||||
|
this.props.store.dispatch(UserActions.applySearchFilter(MIN_SEARCH_LENGTH))
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount = () => {
|
componentWillUnmount = () => {
|
||||||
|
@ -64,12 +74,16 @@ class UsersPane extends React.Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
handleApplyingSearchFilter = () => {
|
handleApplyingSearchFilter = () => {
|
||||||
this.props.store.dispatch(UserActions.applySearchFilter(MIN_SEARCH_LENGTH));
|
this.props.store.dispatch(UserActions.applySearchFilter(MIN_SEARCH_LENGTH))
|
||||||
|
this.updateQueryString()
|
||||||
}
|
}
|
||||||
|
|
||||||
debouncedDispatchApplySearchFilter = _.debounce(() => {
|
updateQueryString = () => {
|
||||||
this.props.store.dispatch(UserActions.applySearchFilter(MIN_SEARCH_LENGTH));
|
const searchFilter = this.props.store.getState().userList.searchFilter
|
||||||
}, 250);
|
this.props.onUpdateQueryParams(searchFilter)
|
||||||
|
}
|
||||||
|
|
||||||
|
debouncedDispatchApplySearchFilter = _.debounce(this.handleApplyingSearchFilter, 250)
|
||||||
|
|
||||||
handleUpdateSearchFilter = (searchFilter) => {
|
handleUpdateSearchFilter = (searchFilter) => {
|
||||||
this.props.store.dispatch(UserActions.updateSearchFilter(searchFilter));
|
this.props.store.dispatch(UserActions.updateSearchFilter(searchFilter));
|
||||||
|
@ -97,14 +111,13 @@ class UsersPane extends React.Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
handleAddNewUserFormErrors = (errors) => {
|
handleAddNewUserFormErrors = (errors) => {
|
||||||
for (const key in errors) {
|
Object.keys(errors).forEach(key => {
|
||||||
this.props.store.dispatch(UserActions.addError({[key]: errors[key]}));
|
this.props.store.dispatch(UserActions.addError({[key]: errors[key]}))
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const {next, timezones, accountId, users, isLoading, errors, searchFilter} = this.state.userList;
|
const {next, timezones, accountId, users, isLoading, errors, searchFilter} = this.state.userList
|
||||||
const collection = {data: users, loading: isLoading, next};
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{<UsersToolbar
|
{<UsersToolbar
|
||||||
|
@ -139,10 +152,12 @@ class UsersPane extends React.Component {
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|
||||||
{renderSearchMessage(collection, this.handleGetMoreUsers, I18n.t('No users found'))}
|
<SearchMessage
|
||||||
|
collection={{data: users, loading: isLoading, next}}
|
||||||
|
loadMore={this.handleGetMoreUsers}
|
||||||
|
noneFoundMessage={I18n.t('No users found')}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default UsersPane
|
|
||||||
|
|
|
@ -16,17 +16,14 @@
|
||||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import I18n from 'i18n!user_actions'
|
export default {
|
||||||
|
selectTab({selected, queryParams}) {
|
||||||
const TabActions = {
|
return {
|
||||||
selectTab(tabIndex) {
|
type: 'SELECT_TAB',
|
||||||
return {
|
payload: {
|
||||||
type: 'SELECT_TAB',
|
selected,
|
||||||
payload: {
|
queryParams
|
||||||
tabIndex: tabIndex
|
}
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
}
|
||||||
export default TabActions
|
|
||||||
|
|
|
@ -18,136 +18,120 @@
|
||||||
|
|
||||||
import $ from 'jquery'
|
import $ from 'jquery'
|
||||||
import UsersStore from 'jsx/account_course_user_search/UsersStore'
|
import UsersStore from 'jsx/account_course_user_search/UsersStore'
|
||||||
import _ from 'underscore'
|
|
||||||
import I18n from 'i18n!user_actions'
|
import I18n from 'i18n!user_actions'
|
||||||
|
|
||||||
const UserActions = {
|
export default {
|
||||||
apiCreateUser (accountId, attributes) {
|
apiCreateUser(accountId, attributes) {
|
||||||
return (dispatch, getState) => {
|
return (dispatch, _getState) => {
|
||||||
|
UsersStore.create(attributes).then((response, _, xhr) => {
|
||||||
UsersStore.create(attributes).then((response, _, xhr) => {
|
dispatch(this.addToUsers([response], xhr))
|
||||||
dispatch(this.addToUsers([response], xhr));
|
})
|
||||||
});
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
addError (error) {
|
|
||||||
return {
|
|
||||||
type: 'ADD_ERROR',
|
|
||||||
error
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
apiGetUsers () {
|
|
||||||
return (dispatch, getState) => {
|
|
||||||
let users = getState().userList.users;
|
|
||||||
if (_.isEmpty(users)) {
|
|
||||||
UsersStore.load({search_term: ''}).then((response, _, xhr) => {
|
|
||||||
dispatch(this.gotUserList(response, xhr));
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
dispatch(this.gotUserList(users));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
apiUpdateUser(attributes, userId) {
|
|
||||||
return (dispatch, getState) => {
|
|
||||||
let url = `/api/v1/users/${userId}`;
|
|
||||||
$.ajaxJSON(url, "PUT", {user: attributes}).then((response) => {
|
|
||||||
dispatch(this.gotUserUpdate(response));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
gotUserList (users, xhr) {
|
|
||||||
return {
|
|
||||||
type: 'GOT_USERS',
|
|
||||||
payload: {
|
|
||||||
users: users,
|
|
||||||
xhr: xhr
|
|
||||||
}
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
gotUserUpdate (user) {
|
|
||||||
return {
|
|
||||||
type: 'GOT_USER_UPDATE',
|
|
||||||
payload: user
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
openEditUserDialog (user) {
|
|
||||||
return {
|
|
||||||
type: 'OPEN_EDIT_USER_DIALOG',
|
|
||||||
payload: user
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
closeEditUserDialog (user) {
|
|
||||||
return {
|
|
||||||
type: 'CLOSE_EDIT_USER_DIALOG',
|
|
||||||
payload: user
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
updateSearchFilter (filter) {
|
|
||||||
return {
|
|
||||||
type: 'UPDATE_SEARCH_FILTER',
|
|
||||||
payload: filter
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
displaySearchTermTooShortError (minSearchLength) {
|
|
||||||
return {
|
|
||||||
type: 'SEARCH_TERM_TOO_SHORT',
|
|
||||||
errors: {
|
|
||||||
termTooShort: I18n.t("Search term must be at least %{num} characters", {num: minSearchLength})
|
|
||||||
}
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
loadingUsers () {
|
|
||||||
return {
|
|
||||||
type: 'LOADING_USERS'
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
addToUsers (users, xhr) {
|
|
||||||
return {
|
|
||||||
type: 'ADD_TO_USERS',
|
|
||||||
payload: {
|
|
||||||
users: users,
|
|
||||||
xhr: xhr
|
|
||||||
}
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
getMoreUsers (store = UsersStore) {
|
|
||||||
return (dispatch, getState) => {
|
|
||||||
let searchFilter = getState().userList.searchFilter;
|
|
||||||
dispatch(this.loadingUsers());
|
|
||||||
store.loadMore(searchFilter).then((response, _, xhr) => {
|
|
||||||
dispatch(this.addToUsers(response, xhr));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
applySearchFilter (minSearchLength, store = UsersStore) {
|
|
||||||
return (dispatch, getState) => {
|
|
||||||
let searchFilter = getState().userList.searchFilter;
|
|
||||||
|
|
||||||
if (searchFilter.search_term.length >= minSearchLength || searchFilter.search_term === "") {
|
|
||||||
dispatch(this.loadingUsers());
|
|
||||||
store.load(searchFilter).then((response, _, xhr) => {
|
|
||||||
dispatch(this.gotUserList(response, xhr));
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
dispatch(this.displaySearchTermTooShortError(minSearchLength));
|
|
||||||
}
|
|
||||||
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
|
||||||
export default UserActions
|
addError(error) {
|
||||||
|
return {
|
||||||
|
type: 'ADD_ERROR',
|
||||||
|
error
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
apiUpdateUser(attributes, userId) {
|
||||||
|
return (dispatch, _getState) => {
|
||||||
|
const url = `/api/v1/users/${userId}`
|
||||||
|
$.ajaxJSON(url, 'PUT', {user: attributes}).then(response => {
|
||||||
|
dispatch(this.gotUserUpdate(response))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
gotUserList(users, xhr) {
|
||||||
|
return {
|
||||||
|
type: 'GOT_USERS',
|
||||||
|
payload: {
|
||||||
|
users,
|
||||||
|
xhr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
gotUserUpdate(user) {
|
||||||
|
return {
|
||||||
|
type: 'GOT_USER_UPDATE',
|
||||||
|
payload: user
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
openEditUserDialog(user) {
|
||||||
|
return {
|
||||||
|
type: 'OPEN_EDIT_USER_DIALOG',
|
||||||
|
payload: user
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
closeEditUserDialog(user) {
|
||||||
|
return {
|
||||||
|
type: 'CLOSE_EDIT_USER_DIALOG',
|
||||||
|
payload: user
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updateSearchFilter(filter) {
|
||||||
|
return {
|
||||||
|
type: 'UPDATE_SEARCH_FILTER',
|
||||||
|
payload: filter
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
displaySearchTermTooShortError(minSearchLength) {
|
||||||
|
return {
|
||||||
|
type: 'SEARCH_TERM_TOO_SHORT',
|
||||||
|
errors: {
|
||||||
|
termTooShort: I18n.t('Search term must be at least %{num} characters', {
|
||||||
|
num: minSearchLength
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
loadingUsers() {
|
||||||
|
return {
|
||||||
|
type: 'LOADING_USERS'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
addToUsers(users, xhr) {
|
||||||
|
return {
|
||||||
|
type: 'ADD_TO_USERS',
|
||||||
|
payload: {
|
||||||
|
users,
|
||||||
|
xhr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getMoreUsers(store = UsersStore) {
|
||||||
|
return (dispatch, getState) => {
|
||||||
|
const searchFilter = getState().userList.searchFilter
|
||||||
|
dispatch(this.loadingUsers())
|
||||||
|
store.loadMore(searchFilter).then((response, _, xhr) => {
|
||||||
|
dispatch(this.addToUsers(response, xhr))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
applySearchFilter(minSearchLength, store = UsersStore) {
|
||||||
|
return (dispatch, getState) => {
|
||||||
|
const searchFilter = getState().userList.searchFilter
|
||||||
|
|
||||||
|
if (!searchFilter || searchFilter.search_term.length >= minSearchLength || searchFilter.search_term === '') {
|
||||||
|
dispatch(this.loadingUsers())
|
||||||
|
store.load(searchFilter).then((response, _, xhr) => {
|
||||||
|
dispatch(this.gotUserList(response, xhr))
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
dispatch(this.displaySearchTermTooShortError(minSearchLength))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -17,66 +17,48 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import PropTypes from 'prop-types'
|
import {string, bool, shape} from 'prop-types'
|
||||||
import ReactTabs from 'react-tabs'
|
import {stringify} from 'qs'
|
||||||
import permissionFilter from 'jsx/shared/helpers/permissionFilter'
|
import permissionFilter from '../shared/helpers/permissionFilter'
|
||||||
import CoursesStore from './CoursesStore'
|
import CoursesStore from './CoursesStore'
|
||||||
import TermsStore from './TermsStore'
|
import TermsStore from './TermsStore'
|
||||||
import AccountsTreeStore from './AccountsTreeStore'
|
import AccountsTreeStore from './AccountsTreeStore'
|
||||||
import UsersStore from './UsersStore'
|
import UsersStore from './UsersStore'
|
||||||
|
|
||||||
const { Tab, Tabs, TabList, TabPanel } = ReactTabs
|
|
||||||
const { string, bool, shape } = PropTypes
|
|
||||||
|
|
||||||
const stores = [CoursesStore, TermsStore, AccountsTreeStore, UsersStore]
|
const stores = [CoursesStore, TermsStore, AccountsTreeStore, UsersStore]
|
||||||
|
|
||||||
class AccountCourseUserSearch extends React.Component {
|
export default class AccountCourseUserSearch extends React.Component {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
accountId: string.isRequired,
|
accountId: string.isRequired,
|
||||||
permissions: shape({
|
permissions: shape({
|
||||||
theme_editor: bool.isRequired,
|
analytics: bool.isRequired
|
||||||
analytics: bool.isRequired
|
}).isRequired
|
||||||
}).isRequired
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillMount () {
|
|
||||||
stores.forEach((s) => {
|
|
||||||
s.reset({ accountId: this.props.accountId });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
render () {
|
|
||||||
const { timezones, permissions, store } = this.props
|
|
||||||
|
|
||||||
const tabList = store.getState().tabList;
|
|
||||||
const tabs = permissionFilter(tabList.tabs, permissions);
|
|
||||||
|
|
||||||
const headers = tabs.map((tab, index) => {
|
|
||||||
return (
|
|
||||||
<Tab key={index}>
|
|
||||||
<a href={tabList.basePath + tab.path} title={tab.title}>{tab.title}</a>
|
|
||||||
</Tab>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const panels = tabs.map((tab, index) => {
|
|
||||||
const Pane = tab.pane;
|
|
||||||
return (
|
|
||||||
<TabPanel key={index}>
|
|
||||||
<Pane {...this.props} />
|
|
||||||
</TabPanel>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tabs selectedIndex={tabList.selected}>
|
|
||||||
<TabList>
|
|
||||||
{headers}
|
|
||||||
</TabList>
|
|
||||||
{panels}
|
|
||||||
</Tabs>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default AccountCourseUserSearch
|
componentWillMount() {
|
||||||
|
stores.forEach(s => {
|
||||||
|
s.reset({accountId: this.props.accountId})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
updateQueryParams(params) {
|
||||||
|
const query = stringify(params)
|
||||||
|
window.history.replaceState(null, null, `?${query}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const tabList = this.props.store.getState().tabList
|
||||||
|
const tabs = permissionFilter(tabList.tabs, this.props.permissions)
|
||||||
|
const ActivePane = tabs[tabList.selected].pane
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ActivePane
|
||||||
|
{...{
|
||||||
|
...this.props,
|
||||||
|
onUpdateQueryParams: this.updateQueryParams,
|
||||||
|
queryParams: tabList.queryParams
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -16,125 +16,125 @@
|
||||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { combineReducers } from 'redux'
|
import {combineReducers} from 'redux'
|
||||||
import _ from 'underscore'
|
|
||||||
import UserActions from '../actions/UserActions'
|
|
||||||
import parseLinkHeader from 'compiled/fn/parseLinkHeader'
|
import parseLinkHeader from 'compiled/fn/parseLinkHeader'
|
||||||
import initialState from 'jsx/account_course_user_search/store/initialState'
|
import initialState from '../store/initialState'
|
||||||
|
|
||||||
/**
|
const emailRegex = /([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})/i
|
||||||
|
|
||||||
|
/**
|
||||||
* Handles setting the editUserDialogOpen state
|
* Handles setting the editUserDialogOpen state
|
||||||
* state - the redux state
|
* state - the redux state
|
||||||
* action - the redux action
|
* action - the redux action
|
||||||
* visibility - boolean that editUserDialogOpen should be set to.
|
* visibility - boolean that editUserDialogOpen should be set to.
|
||||||
*/
|
*/
|
||||||
function setEditUserDialogOpenState (state, action, visibility) {
|
function setEditUserDialogOpenState(state, action, visibility) {
|
||||||
const userObject = _.find(state.users, (user) => {
|
return {
|
||||||
return user.id === action.payload.id;
|
...state,
|
||||||
});
|
users: state.users.map(user => {
|
||||||
|
if (user.id === action.payload.id) {
|
||||||
const userIndex = state.users.indexOf(userObject);
|
return {...user, editUserDialogOpen: visibility}
|
||||||
if (userIndex > -1) {
|
}
|
||||||
state.users[userIndex].editUserDialogOpen = visibility;
|
return user
|
||||||
}
|
})
|
||||||
return state;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const userListHandlers = {
|
const userListHandlers = {
|
||||||
ADD_ERROR: (state, action) => {
|
ADD_ERROR(state, action) {
|
||||||
const errors = _.extend({}, state.errors);
|
return {
|
||||||
state.errors = _.extend(errors, action.error);
|
...state,
|
||||||
return state;
|
errors: {
|
||||||
},
|
...state.errors,
|
||||||
ADD_TO_USERS: (state, action) => {
|
...action.error
|
||||||
if (action.payload.xhr) {
|
|
||||||
const {next} = parseLinkHeader(action.payload.xhr);
|
|
||||||
state.next = next;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
const mappedEmailUsers = action.payload.users.map((user) => {
|
},
|
||||||
if (user.email) {
|
ADD_TO_USERS: (state, action) => {
|
||||||
return user;
|
const mappedEmailUsers = action.payload.users.map(user => {
|
||||||
} else {
|
if (!user.email && emailRegex.test(user.login_id)) {
|
||||||
if (user.login_id && user.login_id.match(/([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})/i)) {
|
return {...user, email: user.login_id}
|
||||||
user.email = user.login_id;
|
|
||||||
}
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
state.users = state.users.concat(mappedEmailUsers);
|
|
||||||
state.isLoading = false;
|
|
||||||
return state;
|
|
||||||
},
|
|
||||||
GOT_USERS: (state, action) => {
|
|
||||||
const { next } = parseLinkHeader(action.payload.xhr);
|
|
||||||
state.users = action.payload.users;
|
|
||||||
state.isLoading = false;
|
|
||||||
state.next = next;
|
|
||||||
return state;
|
|
||||||
},
|
|
||||||
GOT_USER_UPDATE: (state, action) => {
|
|
||||||
const userObject = _.find(state.users, (user) => {
|
|
||||||
return user.id === action.payload.id;
|
|
||||||
});
|
|
||||||
|
|
||||||
const userIndex = state.users.indexOf(userObject);
|
|
||||||
if (userIndex > -1) {
|
|
||||||
state.users[userIndex] = action.payload;
|
|
||||||
}
|
}
|
||||||
return state;
|
return user
|
||||||
},
|
})
|
||||||
OPEN_EDIT_USER_DIALOG: (state, action) => {
|
|
||||||
return setEditUserDialogOpenState(state, action, true);
|
const newState = {
|
||||||
},
|
isLoading: false,
|
||||||
CLOSE_EDIT_USER_DIALOG: (state, action) => {
|
users: state.users.concat(mappedEmailUsers)
|
||||||
return setEditUserDialogOpenState(state, action, false);
|
}
|
||||||
},
|
if (action.payload.xhr) {
|
||||||
UPDATE_SEARCH_FILTER: (state, action) => {
|
newState.next = parseLinkHeader(action.payload.xhr).next
|
||||||
state.searchFilter = _.extend({}, state.searchFilter, action.payload);
|
}
|
||||||
state.errors = {
|
return {...state, ...newState}
|
||||||
|
},
|
||||||
|
GOT_USERS(state, action) {
|
||||||
|
const {next} = parseLinkHeader(action.payload.xhr)
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
users: action.payload.users,
|
||||||
|
isLoading: false,
|
||||||
|
next
|
||||||
|
}
|
||||||
|
},
|
||||||
|
GOT_USER_UPDATE(state, action) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
users: state.users.map(user => (user.id === action.payload.id ? action.payload : user))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
OPEN_EDIT_USER_DIALOG(state, action) {
|
||||||
|
return setEditUserDialogOpenState(state, action, true)
|
||||||
|
},
|
||||||
|
CLOSE_EDIT_USER_DIALOG(state, action) {
|
||||||
|
return setEditUserDialogOpenState(state, action, false)
|
||||||
|
},
|
||||||
|
UPDATE_SEARCH_FILTER(state, action) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
errors: {
|
||||||
search_term: ''
|
search_term: ''
|
||||||
};
|
},
|
||||||
return state;
|
searchFilter: {
|
||||||
},
|
...state.searchFilter,
|
||||||
SEARCH_TERM_TOO_SHORT: (state, action) => {
|
...action.payload
|
||||||
state.errors.search_term = action.errors.termTooShort;
|
}
|
||||||
return state;
|
|
||||||
},
|
|
||||||
LOADING_USERS: (state, action) => {
|
|
||||||
state.isLoading = true;
|
|
||||||
return state;
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
};
|
SEARCH_TERM_TOO_SHORT(state, action) {
|
||||||
|
return {
|
||||||
const userList = (state = initialState, action) => {
|
...state,
|
||||||
if (userListHandlers[action.type]) {
|
errors: {
|
||||||
const newState = _.extend({}, state);
|
...state.errors,
|
||||||
return userListHandlers[action.type](newState, action);
|
search_term: action.errors.termTooShort
|
||||||
} else {
|
}
|
||||||
return state;
|
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
LOADING_USERS(state, _action) {
|
||||||
const tabListHandlers = {
|
return {
|
||||||
SELECT_TAB: (state, action) => {
|
...state,
|
||||||
state.selected = action.payload.tabIndex;
|
isLoading: true
|
||||||
return state;
|
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const tabList = (state = initialState, action) => {
|
const tabListHandlers = {
|
||||||
if (tabListHandlers[action.type]) {
|
SELECT_TAB(state, action) {
|
||||||
const newState = _.extend({}, state);
|
const {selected, queryParams} = action.payload
|
||||||
return tabListHandlers[action.type](newState, action);
|
return {
|
||||||
} else {
|
...state,
|
||||||
return state;
|
selected,
|
||||||
|
queryParams
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const makeReducer = handlerList => (state = initialState, action) => {
|
||||||
|
const handler = handlerList[action.type]
|
||||||
|
if (handler) return handler({...state}, action)
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
export default combineReducers({
|
export default combineReducers({
|
||||||
userList,
|
userList: makeReducer(userListHandlers),
|
||||||
tabList
|
tabList: makeReducer(tabListHandlers)
|
||||||
});
|
})
|
||||||
|
|
|
@ -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 page from 'page'
|
||||||
|
import {parse} from 'qs'
|
||||||
import TabActions from './actions/TabActions'
|
import TabActions from './actions/TabActions'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
start(store) {
|
||||||
|
const tabList = store.getState().tabList
|
||||||
|
|
||||||
const router = {
|
page.base(tabList.basePath)
|
||||||
start: (store) => {
|
|
||||||
const tabList = store.getState().tabList;
|
|
||||||
|
|
||||||
page.base(tabList.basePath);
|
tabList.tabs.forEach((tab, i) => {
|
||||||
|
page(tab.path, context => {
|
||||||
|
store.dispatch(
|
||||||
|
TabActions.selectTab({
|
||||||
|
selected: i,
|
||||||
|
queryParams: parse(context.querystring)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
tabList.tabs.forEach((tab, i) => {
|
page.start()
|
||||||
page(tab.path, (ctx) => {
|
}
|
||||||
store.dispatch(TabActions.selectTab( i ));
|
}
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
if (tabList.tabs.length)
|
|
||||||
page('/', tabList.tabs[0].path);
|
|
||||||
|
|
||||||
page.start();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
export default router
|
|
||||||
|
|
|
@ -18,22 +18,20 @@
|
||||||
|
|
||||||
import tabList from './tabList'
|
import tabList from './tabList'
|
||||||
|
|
||||||
const initialState = {
|
export default {
|
||||||
userList: {
|
userList: {
|
||||||
users: [],
|
users: [],
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
errors: {search_term: ''},
|
errors: {search_term: ''},
|
||||||
next: undefined,
|
next: undefined,
|
||||||
searchFilter: {search_term: ''},
|
searchFilter: {search_term: ''},
|
||||||
timezones: window.ENV.TIMEZONES,
|
timezones: window.ENV.TIMEZONES,
|
||||||
permissions: window.ENV.PERMISSIONS,
|
permissions: window.ENV.PERMISSIONS,
|
||||||
accountId: window.ENV.ACCOUNT_ID
|
accountId: window.ENV.ACCOUNT_ID
|
||||||
},
|
},
|
||||||
tabList: {
|
tabList: {
|
||||||
basePath: '',
|
basePath: '',
|
||||||
tabs: tabList,
|
tabs: tabList,
|
||||||
selected: 0
|
selected: 0
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
export default initialState
|
|
||||||
|
|
|
@ -20,20 +20,19 @@ import I18n from 'i18n!account_course_user_search'
|
||||||
import CoursesPane from '../CoursesPane'
|
import CoursesPane from '../CoursesPane'
|
||||||
import UsersPane from '../UsersPane'
|
import UsersPane from '../UsersPane'
|
||||||
|
|
||||||
const tabs = [
|
export default [
|
||||||
{
|
{
|
||||||
title: I18n.t('Courses'),
|
pane: CoursesPane,
|
||||||
pane: CoursesPane,
|
path: '',
|
||||||
path: '/courses',
|
title: I18n.t('Courses'),
|
||||||
permissions: ['can_read_course_list']
|
permissions: ['can_read_course_list'],
|
||||||
},
|
button_class: 'courses'
|
||||||
{
|
},
|
||||||
title: I18n.t('People'),
|
{
|
||||||
pane: UsersPane,
|
pane: UsersPane,
|
||||||
path: '/people',
|
path: '/users',
|
||||||
permissions: ['can_read_roster']
|
title: I18n.t('People'),
|
||||||
}
|
permissions: ['can_read_roster'],
|
||||||
];
|
button_class: 'users'
|
||||||
|
}
|
||||||
|
]
|
||||||
export default tabs
|
|
||||||
|
|
|
@ -16,6 +16,7 @@
|
||||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import $ from 'jquery'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import ReactDOM from 'react-dom'
|
import ReactDOM from 'react-dom'
|
||||||
import App from 'jsx/account_course_user_search/index'
|
import App from 'jsx/account_course_user_search/index'
|
||||||
|
@ -23,28 +24,47 @@ import router from 'jsx/account_course_user_search/router'
|
||||||
import configureStore from 'jsx/account_course_user_search/store/configureStore'
|
import configureStore from 'jsx/account_course_user_search/store/configureStore'
|
||||||
import initialState from 'jsx/account_course_user_search/store/initialState'
|
import initialState from 'jsx/account_course_user_search/store/initialState'
|
||||||
|
|
||||||
if (location.pathname.indexOf(ENV.BASE_PATH) === -1) {
|
// eg: '/accounts/xxx' for anything like '/accounts/xxx/whatever`
|
||||||
location.replace(ENV.BASE_PATH)
|
initialState.tabList.basePath = window.location.pathname.match(/.*accounts\/[^/]*/)[0]
|
||||||
} else {
|
|
||||||
initialState.tabList.basePath = ENV.BASE_PATH
|
|
||||||
|
|
||||||
const content = document.getElementById('content')
|
// Note. Only the UsersPane/Tab is using a redux store. The courses tab is
|
||||||
|
// still using the old store model. That is why this might seem kind of weird.
|
||||||
|
const store = configureStore(initialState)
|
||||||
|
|
||||||
// Note. Only the UsersPane/Tab is using a redux store. The courses tab is
|
const props = {
|
||||||
// still using the old store model. That is why this might seem kind of weird.
|
permissions: ENV.PERMISSIONS,
|
||||||
const store = configureStore(initialState)
|
accountId: ENV.ACCOUNT_ID.toString(),
|
||||||
|
roles: Array.prototype.slice.call(ENV.COURSE_ROLES),
|
||||||
|
addUserUrls: ENV.URLS,
|
||||||
|
store
|
||||||
|
}
|
||||||
|
|
||||||
const options = {
|
// this is where we take care of the 3 things we need to do outside of the
|
||||||
permissions: ENV.PERMISSIONS,
|
// happy React/redux declarative/vDOM blessed path. It's so when we click
|
||||||
accountId: ENV.ACCOUNT_ID.toString(),
|
// either the "Courses" or "People" tabs on the left, it highlights the right
|
||||||
roles: Array.prototype.slice.call(ENV.COURSE_ROLES),
|
// tab and updates the crumb and document title
|
||||||
addUserUrls: ENV.URLS,
|
const originalDocumentTitle = document.title
|
||||||
store
|
function updateDocumentTitleBreadcrumbAndActiveTab(activeTab) {
|
||||||
}
|
// give the correct left nav item an active class
|
||||||
|
$('#section-tabs .section a').each(function() {
|
||||||
store.subscribe(() => {
|
const $tab = $(this)
|
||||||
ReactDOM.render(<App {...options} />, content)
|
$tab[$tab.hasClass(activeTab.button_class) ? 'addClass' : 'removeClass']('active')
|
||||||
})
|
})
|
||||||
|
|
||||||
router.start(store)
|
// update the page title
|
||||||
|
document.title = `${activeTab.title}: ${originalDocumentTitle}`
|
||||||
|
|
||||||
|
// toggle the breadcrumb between "Corses" and "People"
|
||||||
|
$('#breadcrumbs a:last span').text(activeTab.title)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const content = document.getElementById('content')
|
||||||
|
store.subscribe(() => {
|
||||||
|
const tabState = store.getState().tabList
|
||||||
|
const selectedTab = tabState.tabs[tabState.selected]
|
||||||
|
updateDocumentTitleBreadcrumbAndActiveTab(selectedTab)
|
||||||
|
|
||||||
|
ReactDOM.render(<App {...props} />, content)
|
||||||
|
})
|
||||||
|
|
||||||
|
router.start(store)
|
||||||
|
|
|
@ -17,18 +17,10 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
||||||
function permissionFilter(items, perms) {
|
/**
|
||||||
return items.filter(item => {
|
* filters `items` to just the ones who don't require any unavailable permissions.
|
||||||
let keep = true;
|
*/
|
||||||
|
export default (items, availablePermissions) => {
|
||||||
if (item.permissions && item.permissions.length) {
|
const permissionIsAvailable = p => availablePermissions[p]
|
||||||
keep = item.permissions.reduce((prevPerm, curPerm) => {
|
return items.filter(item => (item.permissions || []).every(permissionIsAvailable))
|
||||||
return prevPerm && perms[curPerm];
|
}
|
||||||
}, keep);
|
|
||||||
}
|
|
||||||
|
|
||||||
return keep;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export default permissionFilter
|
|
||||||
|
|
|
@ -1373,13 +1373,7 @@ class Account < ActiveRecord::Base
|
||||||
manage_settings = user && self.grants_right?(user, :manage_account_settings)
|
manage_settings = user && self.grants_right?(user, :manage_account_settings)
|
||||||
if root_account.site_admin?
|
if root_account.site_admin?
|
||||||
tabs = []
|
tabs = []
|
||||||
if user && self.grants_right?(user, :read_roster)
|
tabs << { :id => TAB_USERS, :label => t("People"), :css_class => 'users', :href => :account_users_path } if user && self.grants_right?(user, :read_roster)
|
||||||
if feature_enabled?(:course_user_search)
|
|
||||||
tabs << { :id => TAB_SEARCH, :label => t("Courses & People"), :css_class => 'search', :href => :account_course_user_search_path }
|
|
||||||
else
|
|
||||||
tabs << { :id => TAB_USERS, :label => t('#account.tab_users', "Users"), :css_class => 'users', :href => :account_users_path }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
tabs << { :id => TAB_PERMISSIONS, :label => t('#account.tab_permissions', "Permissions"), :css_class => 'permissions', :href => :account_permissions_path } if user && self.grants_right?(user, :manage_role_overrides)
|
tabs << { :id => TAB_PERMISSIONS, :label => t('#account.tab_permissions', "Permissions"), :css_class => 'permissions', :href => :account_permissions_path } if user && self.grants_right?(user, :manage_role_overrides)
|
||||||
tabs << { :id => TAB_SUB_ACCOUNTS, :label => t('#account.tab_sub_accounts', "Sub-Accounts"), :css_class => 'sub_accounts', :href => :account_sub_accounts_path } if manage_settings
|
tabs << { :id => TAB_SUB_ACCOUNTS, :label => t('#account.tab_sub_accounts', "Sub-Accounts"), :css_class => 'sub_accounts', :href => :account_sub_accounts_path } if manage_settings
|
||||||
tabs << { :id => TAB_AUTHENTICATION, :label => t('#account.tab_authentication', "Authentication"), :css_class => 'authentication', :href => :account_authentication_providers_path } if root_account? && manage_settings
|
tabs << { :id => TAB_AUTHENTICATION, :label => t('#account.tab_authentication', "Authentication"), :css_class => 'authentication', :href => :account_authentication_providers_path } if root_account? && manage_settings
|
||||||
|
@ -1387,12 +1381,8 @@ class Account < ActiveRecord::Base
|
||||||
tabs << { :id => TAB_JOBS, :label => t("#account.tab_jobs", "Jobs"), :css_class => "jobs", :href => :jobs_path, :no_args => true } if root_account? && self.grants_right?(user, :view_jobs)
|
tabs << { :id => TAB_JOBS, :label => t("#account.tab_jobs", "Jobs"), :css_class => "jobs", :href => :jobs_path, :no_args => true } if root_account? && self.grants_right?(user, :view_jobs)
|
||||||
else
|
else
|
||||||
tabs = []
|
tabs = []
|
||||||
if feature_enabled?(:course_user_search)
|
tabs << { :id => TAB_COURSES, :label => t('#account.tab_courses', "Courses"), :css_class => 'courses', :href => :account_path } if user && self.grants_right?(user, :read_course_list)
|
||||||
tabs << { :id => TAB_SEARCH, :label => t("Courses & People"), :css_class => 'search', :href => :account_path } if user && (grants_right?(user, :read_course_list) || grants_right?(user, :read_roster))
|
tabs << { :id => TAB_USERS, :label => t("People"), :css_class => 'users', :href => :account_users_path } if user && self.grants_right?(user, :read_roster)
|
||||||
else
|
|
||||||
tabs << { :id => TAB_COURSES, :label => t('#account.tab_courses', "Courses"), :css_class => 'courses', :href => :account_path } if user && self.grants_right?(user, :read_course_list)
|
|
||||||
tabs << { :id => TAB_USERS, :label => t('#account.tab_users', "Users"), :css_class => 'users', :href => :account_users_path } if user && self.grants_right?(user, :read_roster)
|
|
||||||
end
|
|
||||||
tabs << { :id => TAB_STATISTICS, :label => t('#account.tab_statistics', "Statistics"), :css_class => 'statistics', :href => :statistics_account_path } if user && self.grants_right?(user, :view_statistics)
|
tabs << { :id => TAB_STATISTICS, :label => t('#account.tab_statistics', "Statistics"), :css_class => 'statistics', :href => :statistics_account_path } if user && self.grants_right?(user, :view_statistics)
|
||||||
tabs << { :id => TAB_PERMISSIONS, :label => t('#account.tab_permissions', "Permissions"), :css_class => 'permissions', :href => :account_permissions_path } if user && self.grants_right?(user, :manage_role_overrides)
|
tabs << { :id => TAB_PERMISSIONS, :label => t('#account.tab_permissions', "Permissions"), :css_class => 'permissions', :href => :account_permissions_path } if user && self.grants_right?(user, :manage_role_overrides)
|
||||||
if user && self.grants_right?(user, :manage_outcomes)
|
if user && self.grants_right?(user, :manage_outcomes)
|
||||||
|
|
|
@ -41,36 +41,6 @@
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.react-tabs ul[role=tablist] {
|
|
||||||
padding-left: 10px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
border-color: $ic-border-light;
|
|
||||||
|
|
||||||
li[role=tab] {
|
|
||||||
font-size: 1.1em;
|
|
||||||
color: var(--ic-link-color);
|
|
||||||
padding: 0;
|
|
||||||
|
|
||||||
a {
|
|
||||||
padding: 10px 25px;
|
|
||||||
display: block;
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
&[selected] {
|
|
||||||
font-weight: bold;
|
|
||||||
border-color: $ic-border-light;
|
|
||||||
|
|
||||||
a:link,
|
|
||||||
a:visited,
|
|
||||||
a:hover {
|
|
||||||
text-decoration: none;
|
|
||||||
color: $ic-font-color-dark;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.button-group {
|
.button-group {
|
||||||
margin-right: 10px;
|
margin-right: 10px;
|
||||||
&:last-of-type {
|
&:last-of-type {
|
||||||
|
|
|
@ -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
|
# You should have received a copy of the GNU Affero General Public License along
|
||||||
# with this program. If not, see <http://www.gnu.org/licenses/>.
|
# with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
%>
|
|
||||||
|
|
||||||
<%
|
|
||||||
@active_tab = "settings"
|
@active_tab = "settings"
|
||||||
add_crumb t(:settings_crumb, "Settings")
|
add_crumb t(:settings_crumb, "Settings")
|
||||||
js_bundle :account_settings
|
js_bundle :account_settings
|
||||||
|
@ -33,10 +30,7 @@
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
%>
|
%>
|
||||||
|
|
||||||
|
|
||||||
<% content_for :right_side do %>
|
<% content_for :right_side do %>
|
||||||
<%= render :partial => "courses_right_side" unless @account.site_admin? %>
|
|
||||||
<%= render :partial => "additional_settings_right_side" %>
|
<%= render :partial => "additional_settings_right_side" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<h1 class='screenreader-only'><%= t(:page_header_title, "Account Settings") %></h1>
|
<h1 class='screenreader-only'><%= t(:page_header_title, "Account Settings") %></h1>
|
||||||
|
|
|
@ -14,19 +14,14 @@
|
||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License along
|
# You should have received a copy of the GNU Affero General Public License along
|
||||||
# with this program. If not, see <http://www.gnu.org/licenses/>.
|
# with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
add_crumb t('titles.sub_accounts', "Sub-Accounts")
|
||||||
|
@active_tab = "sub_accounts"
|
||||||
|
content_for :page_title, t('titles.sub_accounts', 'Sub-Accounts')
|
||||||
|
css_bundle :sub_accounts
|
||||||
|
js_bundle :sub_accounts
|
||||||
%>
|
%>
|
||||||
|
|
||||||
<% add_crumb t('titles.sub_accounts', "Sub-Accounts") %>
|
|
||||||
<% @active_tab = "sub_accounts" %>
|
|
||||||
<% content_for :page_title do %><%= t('titles.sub_accounts', 'Sub-Accounts') %><% end %>
|
|
||||||
|
|
||||||
<% content_for :right_side do %>
|
|
||||||
<%= render :partial => 'shared/accounts_right_side_shared' %>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<% css_bundle :sub_accounts %>
|
|
||||||
<% js_bundle :sub_accounts %>
|
|
||||||
|
|
||||||
<h1 class="screenreader-only"><%= t :page_header_title, 'Sub Accounts' %></h1>
|
<h1 class="screenreader-only"><%= t :page_header_title, 'Sub Accounts' %></h1>
|
||||||
<div id="sub_account_urls">
|
<div id="sub_account_urls">
|
||||||
<a href="<%= context_url(@context, :context_sub_account_url, "{{ id }}") %>" class="sub_account_url" style="display: none;"> </a>
|
<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/bookmarks' => 'users#bookmark_search', as: :bookmark_search
|
||||||
get 'search/rubrics' => 'search#rubrics'
|
get 'search/rubrics' => 'search#rubrics'
|
||||||
get 'search/all_courses' => 'search#all_courses'
|
get 'search/all_courses' => 'search#all_courses'
|
||||||
resources :users, except: :destroy do
|
resources :users, except: [:destroy, :index] do
|
||||||
match 'masquerade', via: [:get, :post]
|
match 'masquerade', via: [:get, :post]
|
||||||
concerns :files, :file_images
|
concerns :files, :file_images
|
||||||
|
|
||||||
|
|
|
@ -46,7 +46,6 @@
|
||||||
"react-modal": "1.6.5",
|
"react-modal": "1.6.5",
|
||||||
"react-redux": "4.4.5",
|
"react-redux": "4.4.5",
|
||||||
"react-select-box": "https://github.com/instructure-react/react-select-box.git#b1ddd39223d48793fbe3dc4e87aca00d57197b5f",
|
"react-select-box": "https://github.com/instructure-react/react-select-box.git#b1ddd39223d48793fbe3dc4e87aca00d57197b5f",
|
||||||
"react-tabs": "0.8.2",
|
|
||||||
"react-tokeninput": "2.4.0",
|
"react-tokeninput": "2.4.0",
|
||||||
"react-tray": "2.0.4",
|
"react-tray": "2.0.4",
|
||||||
"redux": "^3.5.2",
|
"redux": "^3.5.2",
|
||||||
|
|
|
@ -636,7 +636,6 @@ describe "security" do
|
||||||
|
|
||||||
get "/accounts/#{Account.default.id}/settings"
|
get "/accounts/#{Account.default.id}/settings"
|
||||||
expect(response).to be_success
|
expect(response).to be_success
|
||||||
expect(response.body).not_to match /Find A User/
|
|
||||||
|
|
||||||
get "/accounts/#{Account.default.id}/statistics"
|
get "/accounts/#{Account.default.id}/statistics"
|
||||||
expect(response).to be_success
|
expect(response).to be_success
|
||||||
|
@ -649,7 +648,6 @@ describe "security" do
|
||||||
|
|
||||||
get "/accounts/#{Account.default.id}/settings"
|
get "/accounts/#{Account.default.id}/settings"
|
||||||
expect(response).to be_success
|
expect(response).to be_success
|
||||||
expect(response.body).to match /Find A User/
|
|
||||||
|
|
||||||
get "/accounts/#{Account.default.id}/statistics"
|
get "/accounts/#{Account.default.id}/statistics"
|
||||||
expect(response).to be_success
|
expect(response).to be_success
|
||||||
|
@ -660,13 +658,9 @@ describe "security" do
|
||||||
add_permission :view_statistics
|
add_permission :view_statistics
|
||||||
|
|
||||||
course_factory
|
course_factory
|
||||||
get "/accounts/#{Account.default.id}"
|
|
||||||
expect(response).to be_redirect
|
|
||||||
|
|
||||||
get "/accounts/#{Account.default.id}/settings"
|
get "/accounts/#{Account.default.id}/settings"
|
||||||
expect(response).to be_success
|
expect(response).to be_success
|
||||||
expect(response.body).not_to match /Course Filtering/
|
|
||||||
expect(response.body).not_to match /Find a Course/
|
|
||||||
|
|
||||||
get "/accounts/#{Account.default.id}/statistics"
|
get "/accounts/#{Account.default.id}/statistics"
|
||||||
expect(response).to be_success
|
expect(response).to be_success
|
||||||
|
@ -678,8 +672,6 @@ describe "security" do
|
||||||
get "/accounts/#{Account.default.id}"
|
get "/accounts/#{Account.default.id}"
|
||||||
expect(response).to be_success
|
expect(response).to be_success
|
||||||
expect(response.body).to match /Courses/
|
expect(response.body).to match /Courses/
|
||||||
expect(response.body).to match /Course Filtering/
|
|
||||||
expect(response.body).to match /Find a Course/
|
|
||||||
|
|
||||||
get "/accounts/#{Account.default.id}/statistics"
|
get "/accounts/#{Account.default.id}/statistics"
|
||||||
expect(response).to be_success
|
expect(response).to be_success
|
||||||
|
|
|
@ -35,6 +35,8 @@ test('onUpdateFilters calls debouncedApplyFilters after updating state', () => {
|
||||||
<CoursesPane
|
<CoursesPane
|
||||||
accountId="1"
|
accountId="1"
|
||||||
roles={[{id: '1' }]}
|
roles={[{id: '1' }]}
|
||||||
|
queryParams={{}}
|
||||||
|
onUpdateQueryParams={function(){}}
|
||||||
addUserUrls={{
|
addUserUrls={{
|
||||||
USER_LISTS_URL: '/',
|
USER_LISTS_URL: '/',
|
||||||
ENROLL_USERS_URL: '/',
|
ENROLL_USERS_URL: '/',
|
||||||
|
|
|
@ -48,6 +48,8 @@ test('handleUpdateSearchFilter dispatches applySearchFilter action', (assert) =>
|
||||||
<UsersPane
|
<UsersPane
|
||||||
store={fakeStore}
|
store={fakeStore}
|
||||||
roles={['a']}
|
roles={['a']}
|
||||||
|
queryParams={{}}
|
||||||
|
onUpdateQueryParams={function(){}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -16,281 +16,234 @@
|
||||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
define([
|
import actions from 'jsx/account_course_user_search/actions/UserActions'
|
||||||
'jsx/account_course_user_search/actions/UserActions',
|
import UserStore from 'jsx/account_course_user_search/UsersStore'
|
||||||
'jsx/account_course_user_search/UsersStore'
|
|
||||||
], (actions, UserStore) => {
|
|
||||||
|
|
||||||
const STUDENTS = [
|
const STUDENTS = [
|
||||||
{
|
{
|
||||||
"id": "46",
|
id: '46',
|
||||||
"name": "Irene Adler",
|
name: 'Irene Adler',
|
||||||
"sortable_name": "Adler, Irene",
|
sortable_name: 'Adler, Irene',
|
||||||
"short_name": "Irene Adler",
|
short_name: 'Irene Adler',
|
||||||
"sis_user_id": "957",
|
sis_user_id: '957',
|
||||||
"integration_id": null,
|
integration_id: null,
|
||||||
"sis_login_id": "student57",
|
sis_login_id: 'student57',
|
||||||
"sis_import_id": "1",
|
sis_import_id: '1',
|
||||||
"login_id": "student57",
|
login_id: 'student57',
|
||||||
"email": "brotherhood@example.com",
|
email: 'brotherhood@example.com',
|
||||||
"last_login": null,
|
last_login: null,
|
||||||
"time_zone": "Mountain Time (US & Canada)"
|
time_zone: 'Mountain Time (US & Canada)'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "44",
|
id: '44',
|
||||||
"name": "Saint-John Allerdyce",
|
name: 'Saint-John Allerdyce',
|
||||||
"sortable_name": "Allerdyce, Saint-John",
|
sortable_name: 'Allerdyce, Saint-John',
|
||||||
"short_name": "Saint-John Allerdyce",
|
short_name: 'Saint-John Allerdyce',
|
||||||
"sis_user_id": "955",
|
sis_user_id: '955',
|
||||||
"integration_id": null,
|
integration_id: null,
|
||||||
"sis_login_id": "student55",
|
sis_login_id: 'student55',
|
||||||
"sis_import_id": "1",
|
sis_import_id: '1',
|
||||||
"login_id": "student55",
|
login_id: 'student55',
|
||||||
"email": "brotherhood@example.com",
|
email: 'brotherhood@example.com',
|
||||||
"last_login": null,
|
last_login: null,
|
||||||
"time_zone": "Mountain Time (US & Canada)"
|
time_zone: 'Mountain Time (US & Canada)'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "52",
|
id: '52',
|
||||||
"name": "Michael Baer",
|
name: 'Michael Baer',
|
||||||
"sortable_name": "Baer, Michael",
|
sortable_name: 'Baer, Michael',
|
||||||
"short_name": "Michael Baer",
|
short_name: 'Michael Baer',
|
||||||
"sis_user_id": "963",
|
sis_user_id: '963',
|
||||||
"integration_id": null,
|
integration_id: null,
|
||||||
"sis_login_id": "student63",
|
sis_login_id: 'student63',
|
||||||
"sis_import_id": "1",
|
sis_import_id: '1',
|
||||||
"login_id": "student63",
|
login_id: 'student63',
|
||||||
"email": "marauders@example.com",
|
email: 'marauders@example.com',
|
||||||
"last_login": null,
|
last_login: null,
|
||||||
"time_zone": "Mountain Time (US & Canada)"
|
time_zone: 'Mountain Time (US & Canada)'
|
||||||
}];
|
}
|
||||||
|
]
|
||||||
|
|
||||||
QUnit.module('Account Course User Search Actions');
|
QUnit.module('Account Course User Search Actions')
|
||||||
|
|
||||||
asyncTest('apiCreateUser', () => {
|
test('apiCreateUser', function(assert) {
|
||||||
const server = sinon.fakeServer.create();
|
assert.expect(3)
|
||||||
UserStore.reset({accountId: 1});
|
const server = sinon.fakeServer.create()
|
||||||
|
UserStore.reset({accountId: 1})
|
||||||
|
|
||||||
server.respondWith('POST', '/api/v1/accounts/1/users',
|
server.respondWith('POST', '/api/v1/accounts/1/users', [
|
||||||
[200, { "Content-Type": "application/json" }, JSON.stringify(STUDENTS[0])]
|
200,
|
||||||
);
|
{'Content-Type': 'application/json'},
|
||||||
|
JSON.stringify(STUDENTS[0])
|
||||||
|
])
|
||||||
|
|
||||||
equal(typeof actions.apiCreateUser(1, {}), 'function', 'it initally returns a callback function');
|
equal(typeof actions.apiCreateUser(1, {}), 'function', 'it initally returns a callback function')
|
||||||
|
|
||||||
actions.apiCreateUser(1, {})((response) => {
|
actions.apiCreateUser(1, {})(response => {
|
||||||
equal(response.type, 'ADD_TO_USERS', 'it dispatches the proper action');
|
equal(response.type, 'ADD_TO_USERS', 'it dispatches the proper action')
|
||||||
equal(Array.isArray(response.payload.users), true, 'it returns a users array');
|
equal(Array.isArray(response.payload.users), true, 'it returns a users array')
|
||||||
start();
|
})
|
||||||
});
|
|
||||||
|
|
||||||
server.respond();
|
server.respond()
|
||||||
server.restore();
|
server.restore()
|
||||||
});
|
})
|
||||||
|
|
||||||
test('addError', () => {
|
test('addError', () => {
|
||||||
const message = actions.addError({errorKey: 'error'});
|
const message = actions.addError({errorKey: 'error'})
|
||||||
equal(message.type, 'ADD_ERROR', 'it returns the proper action type');
|
equal(message.type, 'ADD_ERROR', 'it returns the proper action type')
|
||||||
deepEqual(message.error, {errorKey: 'error'}, 'it returns the proper error');
|
deepEqual(message.error, {errorKey: 'error'}, 'it returns the proper error')
|
||||||
});
|
})
|
||||||
|
|
||||||
asyncTest('apiGetUsers', () => {
|
asyncTest('apiUpdateUser', () => {
|
||||||
|
const server = sinon.fakeServer.create()
|
||||||
|
|
||||||
// This will let us start the tests back once all the async stuff finishes.
|
// This is a POST rather than a PUT because of the way our $.getJSON converts
|
||||||
let counter = 2;
|
// non-GET requests to posts anyways.
|
||||||
function done () {
|
server.respondWith('POST', /api\/v1\/users\/1/, [
|
||||||
--counter || start();
|
200,
|
||||||
|
{'Content-Type': 'application/json'},
|
||||||
|
JSON.stringify(STUDENTS[0])
|
||||||
|
])
|
||||||
|
|
||||||
|
equal(
|
||||||
|
typeof actions.apiUpdateUser({name: 'Test'}, 1),
|
||||||
|
'function',
|
||||||
|
'it initally returns a callback function'
|
||||||
|
)
|
||||||
|
|
||||||
|
actions.apiUpdateUser({name: 'Test'}, 1)(response => {
|
||||||
|
equal(response.type, 'GOT_USER_UPDATE', 'it returns the proper action type')
|
||||||
|
deepEqual(response.payload, STUDENTS[0], 'it returns the user in the payload')
|
||||||
|
start()
|
||||||
|
})
|
||||||
|
|
||||||
|
server.respond()
|
||||||
|
server.restore()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('openEditUserDialog', () => {
|
||||||
|
const message = actions.openEditUserDialog(STUDENTS[0])
|
||||||
|
equal(message.type, 'OPEN_EDIT_USER_DIALOG', 'it returns the proper type')
|
||||||
|
deepEqual(message.payload, STUDENTS[0], 'the payload contains the user')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('closeEditUserDialog', () => {
|
||||||
|
const message = actions.closeEditUserDialog(STUDENTS[0])
|
||||||
|
equal(message.type, 'CLOSE_EDIT_USER_DIALOG', 'it returns the proper type')
|
||||||
|
deepEqual(message.payload, STUDENTS[0], 'the payload contains the user')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('updateSearchFilter', () => {
|
||||||
|
const message = actions.updateSearchFilter('myFilter')
|
||||||
|
equal(message.type, 'UPDATE_SEARCH_FILTER', 'it returns the proper type')
|
||||||
|
equal(message.payload, 'myFilter', 'the payload contains the filter')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('displaySearchTermTooShortError', () => {
|
||||||
|
const message = actions.displaySearchTermTooShortError(3)
|
||||||
|
equal(message.type, 'SEARCH_TERM_TOO_SHORT', 'it returns the proper type')
|
||||||
|
equal(
|
||||||
|
message.errors.termTooShort,
|
||||||
|
'Search term must be at least 3 characters',
|
||||||
|
'the error is set with the proper number'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('loadingUsers', () => {
|
||||||
|
const message = actions.loadingUsers()
|
||||||
|
equal(message.type, 'LOADING_USERS', 'it returns the proper type')
|
||||||
|
})
|
||||||
|
|
||||||
|
asyncTest('getMoreUsers', () => {
|
||||||
|
let count = 2
|
||||||
|
const done = () => {
|
||||||
|
--count || start()
|
||||||
|
}
|
||||||
|
|
||||||
|
const fakeDispatcher = response => {
|
||||||
|
if (count === 2) {
|
||||||
|
equal(response.type, 'LOADING_USERS', 'it returns the proper action type')
|
||||||
|
done()
|
||||||
|
} else {
|
||||||
|
equal(response.type, 'ADD_TO_USERS', 'it returns the proper action type')
|
||||||
|
deepEqual(response.payload.users[0], STUDENTS[0], 'it returns the user in the payload')
|
||||||
|
done()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const server = sinon.fakeServer.create();
|
const fakeGetState = () => ({
|
||||||
UserStore.reset({accountId: 1});
|
userList: {
|
||||||
|
searchFilter: 'abc',
|
||||||
|
next: '/api/v1/accounts/1/users?page=2'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
server.respondWith('GET', /api\/v1\/accounts\/1\/users/,
|
const fakeUserStore = {
|
||||||
[200, { "Content-Type": "application/json" }, JSON.stringify(STUDENTS)]
|
loadMore(filter) {
|
||||||
);
|
return Promise.resolve([STUDENTS[0]])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
equal(typeof actions.apiGetUsers(), 'function', 'it initally returns a callback function');
|
const actionThunk = actions.getMoreUsers(fakeUserStore)
|
||||||
|
|
||||||
actions.apiGetUsers()((response) => {
|
equal(typeof actionThunk, 'function', 'it initally returns a callback function')
|
||||||
equal(response.type, 'GOT_USERS', 'it returns the proper action type');
|
|
||||||
deepEqual(response.payload.users, STUDENTS, 'it returns the proper data');
|
|
||||||
ok(response.payload.xhr, 'it calls out to the api when state has no users');
|
|
||||||
done();
|
|
||||||
}, () => {
|
|
||||||
return {
|
|
||||||
userList: {
|
|
||||||
users: []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
actions.apiGetUsers()((response) => {
|
actionThunk(fakeDispatcher, fakeGetState)
|
||||||
deepEqual(response.payload.users, STUDENTS, 'it returns the proper data');
|
})
|
||||||
ok(!response.payload.xhr, 'it does not call the api when there is state in the store');
|
|
||||||
done();
|
|
||||||
}, () => {
|
|
||||||
return {
|
|
||||||
userList: {
|
|
||||||
users: STUDENTS
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
server.respond();
|
asyncTest('applySearchFilter', () => {
|
||||||
server.restore();
|
let count = 3
|
||||||
|
const done = () => {
|
||||||
|
--count || start()
|
||||||
|
}
|
||||||
|
|
||||||
});
|
const fakeDispatcherSearchLengthOkay = response => {
|
||||||
|
if (count === 3) {
|
||||||
|
equal(response.type, 'LOADING_USERS', 'it returns the proper action type')
|
||||||
|
done()
|
||||||
|
} else {
|
||||||
|
equal(response.type, 'GOT_USERS', 'it returns the proper action type')
|
||||||
|
deepEqual(response.payload.users[0], STUDENTS[0], 'it returns the user in the payload')
|
||||||
|
done()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
asyncTest('apiUpdateUser', () => {
|
const fakeGetStateSearchLengthOkay = () => ({
|
||||||
const server = sinon.fakeServer.create();
|
userList: {
|
||||||
|
searchFilter: {
|
||||||
// This is a POST rather than a PUT because of the way our $.getJSON converts
|
search_term: 'abcd'
|
||||||
// non-GET requests to posts anyways.
|
|
||||||
server.respondWith('POST', /api\/v1\/users\/1/,
|
|
||||||
[200, { "Content-Type": "application/json" }, JSON.stringify(STUDENTS[0])]
|
|
||||||
);
|
|
||||||
|
|
||||||
equal(typeof actions.apiUpdateUser({name: 'Test'}, 1), 'function', 'it initally returns a callback function');
|
|
||||||
|
|
||||||
actions.apiUpdateUser({name: 'Test'}, 1)((response) => {
|
|
||||||
equal(response.type, 'GOT_USER_UPDATE', 'it returns the proper action type');
|
|
||||||
deepEqual(response.payload, STUDENTS[0], 'it returns the user in the payload');
|
|
||||||
start();
|
|
||||||
});
|
|
||||||
|
|
||||||
server.respond();
|
|
||||||
server.restore();
|
|
||||||
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
test('openEditUserDialog', () => {
|
|
||||||
const message = actions.openEditUserDialog(STUDENTS[0]);
|
|
||||||
equal(message.type, 'OPEN_EDIT_USER_DIALOG', 'it returns the proper type');
|
|
||||||
deepEqual(message.payload, STUDENTS[0], 'the payload contains the user');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('closeEditUserDialog', () => {
|
|
||||||
const message = actions.closeEditUserDialog(STUDENTS[0]);
|
|
||||||
equal(message.type, 'CLOSE_EDIT_USER_DIALOG', 'it returns the proper type');
|
|
||||||
deepEqual(message.payload, STUDENTS[0], 'the payload contains the user');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('updateSearchFilter', () => {
|
|
||||||
const message = actions.updateSearchFilter('myFilter');
|
|
||||||
equal(message.type, 'UPDATE_SEARCH_FILTER', 'it returns the proper type');
|
|
||||||
equal(message.payload, 'myFilter', 'the payload contains the filter');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('displaySearchTermTooShortError', () => {
|
|
||||||
const message = actions.displaySearchTermTooShortError(3);
|
|
||||||
equal(message.type, 'SEARCH_TERM_TOO_SHORT', 'it returns the proper type');
|
|
||||||
equal(message.errors.termTooShort, 'Search term must be at least 3 characters', 'the error is set with the proper number');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('loadingUsers', () => {
|
|
||||||
const message = actions.loadingUsers();
|
|
||||||
equal(message.type, 'LOADING_USERS', 'it returns the proper type');
|
|
||||||
});
|
|
||||||
|
|
||||||
asyncTest('getMoreUsers', () => {
|
|
||||||
|
|
||||||
let count = 2;
|
|
||||||
const done = () => {
|
|
||||||
--count || start();
|
|
||||||
};
|
|
||||||
|
|
||||||
const fakeDispatcher = (response) => {
|
|
||||||
if (count === 2) {
|
|
||||||
equal(response.type, 'LOADING_USERS', 'it returns the proper action type');
|
|
||||||
done();
|
|
||||||
} else {
|
|
||||||
equal(response.type, 'ADD_TO_USERS', 'it returns the proper action type');
|
|
||||||
deepEqual(response.payload.users[0], STUDENTS[0], 'it returns the user in the payload');
|
|
||||||
done();
|
|
||||||
}
|
|
||||||
|
|
||||||
};
|
|
||||||
|
|
||||||
const fakeGetState = () => {
|
|
||||||
return {
|
|
||||||
userList: {
|
|
||||||
searchFilter: 'abc',
|
|
||||||
next: '/api/v1/accounts/1/users?page=2'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const fakeUserStore = {
|
|
||||||
loadMore (filter) {
|
|
||||||
return Promise.resolve([STUDENTS[0]])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const actionThunk = actions.getMoreUsers(fakeUserStore);
|
const fakeDispatcherSearchLengthTooShort = response => {
|
||||||
|
equal(response.type, 'SEARCH_TERM_TOO_SHORT', 'it returns the proper action type')
|
||||||
|
equal(
|
||||||
|
response.errors.termTooShort,
|
||||||
|
'Search term must be at least 4 characters',
|
||||||
|
'the error is set with the proper number'
|
||||||
|
)
|
||||||
|
done()
|
||||||
|
}
|
||||||
|
|
||||||
equal(typeof actionThunk, 'function', 'it initally returns a callback function');
|
const fakeGetStateSearchLengthTooShort = () => ({
|
||||||
|
userList: {
|
||||||
actionThunk(fakeDispatcher, fakeGetState);
|
searchFilter: {
|
||||||
|
search_term: 'a'
|
||||||
});
|
|
||||||
|
|
||||||
asyncTest('applySearchFilter', () => {
|
|
||||||
let count = 3;
|
|
||||||
const done = () => {
|
|
||||||
--count || start();
|
|
||||||
};
|
|
||||||
|
|
||||||
const fakeDispatcherSearchLengthOkay = (response) => {
|
|
||||||
if (count === 3) {
|
|
||||||
equal(response.type, 'LOADING_USERS', 'it returns the proper action type');
|
|
||||||
done();
|
|
||||||
} else {
|
|
||||||
equal(response.type, 'GOT_USERS', 'it returns the proper action type');
|
|
||||||
deepEqual(response.payload.users[0], STUDENTS[0], 'it returns the user in the payload');
|
|
||||||
done();
|
|
||||||
}
|
|
||||||
|
|
||||||
};
|
|
||||||
|
|
||||||
const fakeGetStateSearchLengthOkay = () => {
|
|
||||||
return {
|
|
||||||
userList: {
|
|
||||||
searchFilter: {
|
|
||||||
search_term: 'abcd'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const fakeDispatcherSearchLengthTooShort = (response) => {
|
|
||||||
equal(response.type, 'SEARCH_TERM_TOO_SHORT', 'it returns the proper action type');
|
|
||||||
equal(response.errors.termTooShort, 'Search term must be at least 4 characters', 'the error is set with the proper number');
|
|
||||||
done();
|
|
||||||
};
|
|
||||||
|
|
||||||
const fakeGetStateSearchLengthTooShort = () => {
|
|
||||||
return {
|
|
||||||
userList: {
|
|
||||||
searchFilter: {
|
|
||||||
search_term: 'a'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const fakeUserStore = {
|
|
||||||
load (filter) {
|
|
||||||
return Promise.resolve([STUDENTS[0]]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const actionThunk = actions.applySearchFilter(4, fakeUserStore);
|
const fakeUserStore = {
|
||||||
|
load() {
|
||||||
|
return Promise.resolve([STUDENTS[0]])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
equal(typeof actionThunk, 'function', 'it initally returns a callback function');
|
const actionThunk = actions.applySearchFilter(4, fakeUserStore)
|
||||||
|
|
||||||
actionThunk(fakeDispatcherSearchLengthOkay, fakeGetStateSearchLengthOkay);
|
equal(typeof actionThunk, 'function', 'it initally returns a callback function')
|
||||||
actionThunk(fakeDispatcherSearchLengthTooShort, fakeGetStateSearchLengthTooShort);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
actionThunk(fakeDispatcherSearchLengthOkay, fakeGetStateSearchLengthOkay)
|
||||||
});
|
actionThunk(fakeDispatcherSearchLengthTooShort, fakeGetStateSearchLengthTooShort)
|
||||||
|
})
|
||||||
|
|
|
@ -327,7 +327,7 @@ define(['jsx/account_course_user_search/reducers/rootReducer'], (reducer) => {
|
||||||
const action = {
|
const action = {
|
||||||
type: 'SELECT_TAB',
|
type: 'SELECT_TAB',
|
||||||
payload: {
|
payload: {
|
||||||
tabIndex: 1
|
selected: 1
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -38,8 +38,7 @@ describe "new account course search" do
|
||||||
@account.role_overrides.create! :role => admin_role, :permission => 'read_course_list', :enabled => false
|
@account.role_overrides.create! :role => admin_role, :permission => 'read_course_list', :enabled => false
|
||||||
|
|
||||||
get "/accounts/#{@account.id}"
|
get "/accounts/#{@account.id}"
|
||||||
|
expect(f("#left-side #section-tabs")).not_to include_text("Courses")
|
||||||
expect(f(".react-tabs > ul")).to_not include_text("Courses")
|
|
||||||
end
|
end
|
||||||
|
|
||||||
it "should hide courses without enrollments if checked" do
|
it "should hide courses without enrollments if checked" do
|
||||||
|
|
|
@ -34,33 +34,47 @@ describe "new account user search" do
|
||||||
ff('.users-list div[role=row]')
|
ff('.users-list div[role=row]')
|
||||||
end
|
end
|
||||||
|
|
||||||
def click_tab
|
it "should be able to toggle between 'People' and 'Courses' tabs" do
|
||||||
ff(".react-tabs > ul li").detect{|tab| tab.text.include?("People")}.click
|
user_with_pseudonym(:account => @account, :name => "Test User")
|
||||||
wait_for_ajaximations
|
course_factory(:account => @account, :course_name => "Test Course")
|
||||||
|
|
||||||
|
get "/accounts/#{@account.id}"
|
||||||
|
2.times do
|
||||||
|
expect(f("#breadcrumbs")).not_to include_text("People")
|
||||||
|
expect(f("#breadcrumbs")).to include_text("Courses")
|
||||||
|
expect(f('.courses-list')).to include_text("Test Course")
|
||||||
|
|
||||||
|
f('#section-tabs .users').click
|
||||||
|
expect(driver.current_url).to include("/accounts/#{@account.id}/users")
|
||||||
|
expect(f("#breadcrumbs")).to include_text("People")
|
||||||
|
expect(f("#breadcrumbs")).not_to include_text("Courses")
|
||||||
|
expect(f('.users-list')).to include_text("Test User")
|
||||||
|
|
||||||
|
f('#section-tabs .courses').click
|
||||||
|
end
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
it "should not show the people tab without permission" do
|
it "should not show the people tab without permission" do
|
||||||
@account.role_overrides.create! :role => admin_role, :permission => 'read_roster', :enabled => false
|
@account.role_overrides.create! :role => admin_role, :permission => 'read_roster', :enabled => false
|
||||||
|
|
||||||
get "/accounts/#{@account.id}"
|
get "/accounts/#{@account.id}"
|
||||||
|
|
||||||
expect(f(".react-tabs > ul")).to_not include_text("People")
|
expect(f("#left-side #section-tabs")).not_to include_text("People")
|
||||||
end
|
end
|
||||||
|
|
||||||
it "should not show the create users button for non-root acocunts" do
|
it "should not show the create users button for non-root acocunts" do
|
||||||
sub_account = Account.create!(:name => "sub", :parent_account => @account)
|
sub_account = Account.create!(:name => "sub", :parent_account => @account)
|
||||||
|
|
||||||
get "/accounts/#{sub_account.id}"
|
get "/accounts/#{sub_account.id}/users"
|
||||||
|
|
||||||
click_tab
|
|
||||||
|
|
||||||
expect(f("#content")).not_to contain_css('button.add_user')
|
expect(f("#content")).not_to contain_css('button.add_user')
|
||||||
end
|
end
|
||||||
|
|
||||||
it "should be able to create users" do
|
it "should be able to create users" do
|
||||||
get "/accounts/#{@account.id}"
|
get "/accounts/#{@account.id}/users"
|
||||||
|
|
||||||
click_tab
|
|
||||||
|
|
||||||
f('button.add_user').click
|
f('button.add_user').click
|
||||||
|
|
||||||
|
@ -94,8 +108,7 @@ describe "new account user search" do
|
||||||
user_with_pseudonym(:account => @account, :name => "Test User #{x + 1}")
|
user_with_pseudonym(:account => @account, :name => "Test User #{x + 1}")
|
||||||
end
|
end
|
||||||
|
|
||||||
get "/accounts/#{@account.id}"
|
get "/accounts/#{@account.id}/users"
|
||||||
click_tab
|
|
||||||
|
|
||||||
expect(get_rows.count).to eq 10
|
expect(get_rows.count).to eq 10
|
||||||
|
|
||||||
|
@ -110,8 +123,7 @@ describe "new account user search" do
|
||||||
match_user = user_with_pseudonym(:account => @account, :name => "user with a search term")
|
match_user = user_with_pseudonym(:account => @account, :name => "user with a search term")
|
||||||
user_with_pseudonym(:account => @account, :name => "diffrient user")
|
user_with_pseudonym(:account => @account, :name => "diffrient user")
|
||||||
|
|
||||||
get "/accounts/#{@account.id}"
|
get "/accounts/#{@account.id}/users"
|
||||||
click_tab
|
|
||||||
|
|
||||||
f('.user_search_bar input[type=search]').send_keys('search')
|
f('.user_search_bar input[type=search]').send_keys('search')
|
||||||
|
|
||||||
|
@ -125,8 +137,7 @@ describe "new account user search" do
|
||||||
match_user = user_with_pseudonym(:account => @account, :name => "user with a search term")
|
match_user = user_with_pseudonym(:account => @account, :name => "user with a search term")
|
||||||
user_with_pseudonym(:account => @account, :name => "diffrient user")
|
user_with_pseudonym(:account => @account, :name => "diffrient user")
|
||||||
|
|
||||||
get "/accounts/#{@account.id}"
|
get "/accounts/#{@account.id}/users"
|
||||||
click_tab
|
|
||||||
|
|
||||||
f('#peopleOptionsBtn').click
|
f('#peopleOptionsBtn').click
|
||||||
f('#manageStudentsLink').click
|
f('#manageStudentsLink').click
|
||||||
|
@ -138,8 +149,7 @@ describe "new account user search" do
|
||||||
match_user = user_with_pseudonym(:account => @account, :name => "user with a search term")
|
match_user = user_with_pseudonym(:account => @account, :name => "user with a search term")
|
||||||
user_with_pseudonym(:account => @account, :name => "diffrient user")
|
user_with_pseudonym(:account => @account, :name => "diffrient user")
|
||||||
|
|
||||||
get "/accounts/#{@account.id}"
|
get "/accounts/#{@account.id}/users"
|
||||||
click_tab
|
|
||||||
|
|
||||||
f('#peopleOptionsBtn').click
|
f('#peopleOptionsBtn').click
|
||||||
f('#viewUserGroupLink').click
|
f('#viewUserGroupLink').click
|
||||||
|
|
|
@ -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
|
it "should allow admins to message users from their profiles", priority: "2", test_id: 201940 do
|
||||||
user = account_admin_user
|
user = account_admin_user
|
||||||
user_logged_in({:user => user})
|
user_logged_in({:user => user})
|
||||||
|
|
||||||
|
# TODO: delete these lines when we remove the :course_user_search feature flag
|
||||||
get "/accounts/#{Account.default.id}/users"
|
get "/accounts/#{Account.default.id}/users"
|
||||||
wait_for_ajaximations
|
wait_for_ajaximations
|
||||||
f('li.user a').click
|
f('li.user a').click
|
||||||
|
@ -134,6 +136,13 @@ describe "conversations new" do
|
||||||
f('.icon-email').click
|
f('.icon-email').click
|
||||||
wait_for_ajaximations
|
wait_for_ajaximations
|
||||||
expect(f('.ac-token')).not_to be_nil
|
expect(f('.ac-token')).not_to be_nil
|
||||||
|
Account.default.enable_feature!(:course_user_search)
|
||||||
|
|
||||||
|
get "/accounts/#{Account.default.id}/users"
|
||||||
|
wait_for_ajaximations
|
||||||
|
f('.users-list [role=row] .Button .icon-message').click
|
||||||
|
wait_for_ajaximations
|
||||||
|
expect(f('.ac-token')).not_to be_nil
|
||||||
end
|
end
|
||||||
|
|
||||||
it "should allow selecting multiple recipients in one search", priority: "2", test_id: 201941 do
|
it "should allow selecting multiple recipients in one search", priority: "2", test_id: 201941 do
|
||||||
|
|
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"
|
version "2.1.9"
|
||||||
resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-2.1.9.tgz#f0e80ae039a4bd654b5f281fc93f04a914a7fcce"
|
resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-2.1.9.tgz#f0e80ae039a4bd654b5f281fc93f04a914a7fcce"
|
||||||
|
|
||||||
js-stylesheet@^0.0.1:
|
|
||||||
version "0.0.1"
|
|
||||||
resolved "https://registry.yarnpkg.com/js-stylesheet/-/js-stylesheet-0.0.1.tgz#12cc1451220e454184b46de3b098c0d154762c38"
|
|
||||||
|
|
||||||
js-tokens@1.0.1:
|
js-tokens@1.0.1:
|
||||||
version "1.0.1"
|
version "1.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-1.0.1.tgz#cc435a5c8b94ad15acb7983140fc80182c89aeae"
|
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-1.0.1.tgz#cc435a5c8b94ad15acb7983140fc80182c89aeae"
|
||||||
|
@ -7052,13 +7048,6 @@ react-redux@^5.0.3:
|
||||||
version "2.0.1"
|
version "2.0.1"
|
||||||
resolved "https://github.com/instructure-react/react-select-box.git#b1ddd39223d48793fbe3dc4e87aca00d57197b5f"
|
resolved "https://github.com/instructure-react/react-select-box.git#b1ddd39223d48793fbe3dc4e87aca00d57197b5f"
|
||||||
|
|
||||||
react-tabs@0.8.2:
|
|
||||||
version "0.8.2"
|
|
||||||
resolved "https://registry.yarnpkg.com/react-tabs/-/react-tabs-0.8.2.tgz#7928e822361b61eb7f53164daf86ad7ee98ef539"
|
|
||||||
dependencies:
|
|
||||||
classnames "^2.2.0"
|
|
||||||
js-stylesheet "^0.0.1"
|
|
||||||
|
|
||||||
react-tinymce@0.6.0:
|
react-tinymce@0.6.0:
|
||||||
version "0.6.0"
|
version "0.6.0"
|
||||||
resolved "https://registry.yarnpkg.com/react-tinymce/-/react-tinymce-0.6.0.tgz#729cd1edd00c4214b7d253f02283a7d2b52813aa"
|
resolved "https://registry.yarnpkg.com/react-tinymce/-/react-tinymce-0.6.0.tgz#729cd1edd00c4214b7d253f02283a7d2b52813aa"
|
||||||
|
|
Loading…
Reference in New Issue