new account course search page

also lay foundation for new account user search

test plan:
* in development mode, enable the
 "Course and User Search" feature flag
* should be able to view the "Search" tab on the
 sidebar (may need to resave the account to
 clear the sidebar cache)
 (replaces the "Courses" and "Users" tabs)
* searching for courses on the account page
 should work pretty good
 (the people tab is still forthcoming)

closes #CNVS-24750

Change-Id: Id44d1b3c7c36e407339858d2c1657579d1128abc
Reviewed-on: https://gerrit.instructure.com/65268
Tested-by: Jenkins
Reviewed-by: Jeremy Stanley <jeremy@instructure.com>
QA-Review: Clare Strong <clare@instructure.com>
Product-Review: Cosme Salazar <cosme@instructure.com>
This commit is contained in:
Jon Jensen 2015-10-15 23:03:08 -06:00 committed by James Williams
parent 9d2ec79ec0
commit 9bc2dc05bb
36 changed files with 2268 additions and 9 deletions

View File

@ -0,0 +1,15 @@
require [
'jquery'
'react'
"jsx/account_course_user_search/index"
], ($, React, App) ->
React.render(
React.createElement(
App,
{
accountId: ENV.ACCOUNT_ID.toString(),
permissions: ENV.PERMISSIONS
}
),
$("#content")[0]
)

View File

@ -167,6 +167,7 @@ class AccountsController < ApplicationController
return unless authorized_action(@account, @current_user, :read)
respond_to do |format|
format.html do
return course_user_search if @account.feature_enabled?(:course_user_search)
if value_to_boolean(params[:theme_applied])
flash[:notice] = t("Your custom theme has been successfully applied.")
end
@ -182,6 +183,22 @@ class AccountsController < ApplicationController
end
end
def course_user_search
can_read_course_list = @account.grants_right?(@current_user, session, :read_course_list)
can_read_roster = @account.grants_right?(@current_user, session, :read_roster)
unless can_read_course_list || can_read_roster
return render_unauthorized_action
end
@permissions = {
theme_editor: use_new_styles? && can_do(@account, @current_user, :manage_account_settings) && @account.branding_allowed?,
can_read_course_list: can_read_course_list,
can_read_roster: can_read_roster
}
render template: "accounts/course_user_search"
end
# @API Get the sub-accounts of an account
#
# List accounts that are sub-accounts of the given account.
@ -232,6 +249,10 @@ class AccountsController < ApplicationController
# include only courses with no enrollments. If not present, do not filter
# on course enrollment status.
#
# @argument enrollment_type[] [String, "teacher"|"student"|"ta"|"observer"|"designer"]
# If set, only return courses that have at least one user enrolled in
# in the course with one of the specified enrollment types.
#
# @argument published [Boolean]
# If true, include only published courses. If false, exclude published
# courses. If not present, do not filter on published status.
@ -263,7 +284,7 @@ class AccountsController < ApplicationController
# @argument search_term [String]
# The partial course name, code, or full ID to match and return in the results list. Must be at least 3 characters.
#
# @argument include[] [String, "syllabus_body"|"term"|"course_progress"|"storage_quota_used_mb"]
# @argument include[] [String, "syllabus_body"|"term"|"course_progress"|"storage_quota_used_mb"|"total_students"|"teachers"]
# - All explanations can be seen in the {api:CoursesController#index Course API index documentation}
# - "sections", "needs_grading_count" and "total_scores" are not valid options at the account level
#
@ -286,6 +307,10 @@ class AccountsController < ApplicationController
@courses = @courses.without_enrollments
end
if params[:enrollment_type].is_a?(Array)
@courses = @courses.with_enrollment_types(params[:enrollment_type])
end
if value_to_boolean(params[:completed])
@courses = @courses.completed
elsif !params[:completed].nil? && !value_to_boolean(params[:completed])
@ -332,6 +357,7 @@ class AccountsController < ApplicationController
@courses = Api.paginate(@courses, self, api_v1_account_courses_url)
ActiveRecord::Associations::Preloader.new(@courses, [:account, :root_account]).run
ActiveRecord::Associations::Preloader.new(@courses, [:teachers]).run if includes.include?("teachers")
render :json => @courses.map { |c| course_json(c, @current_user, session, includes, nil) }
end

View File

@ -318,7 +318,7 @@ class CoursesController < ApplicationController
# 'StudentEnrollment', 'TeacherEnrollment', 'TaEnrollment', 'ObserverEnrollment',
# or 'DesignerEnrollment'.
#
# @argument include[] [String, "needs_grading_count"|"syllabus_body"|"total_scores"|"term"|"course_progress"|"sections"|"storage_quota_used_mb"|"total_students"|"favorites"]
# @argument include[] [String, "needs_grading_count"|"syllabus_body"|"total_scores"|"term"|"course_progress"|"sections"|"storage_quota_used_mb"|"total_students"|"favorites"|"teachers"]
# - "needs_grading_count": Optional information to include with each Course.
# When needs_grading_count is given, and the current user has grading
# rights, the total number of submissions needing grading for all
@ -362,6 +362,9 @@ class CoursesController < ApplicationController
# - "passback_status": Include the grade passback_status
# - "favorites": Optional information to include with each Course.
# Indicates if the user has marked the course as a favorite course.
# - "teachers": Teacher information to include with each Course.
# Returns an array of hashes containing the {{api:Users:UserDisplay UserDisplay} information
# for each teacher in the course.
#
# @argument state[] [String, "unpublished"|"available"|"completed"|"deleted"]
# If set, only return courses that are in the given state(s).
@ -404,7 +407,6 @@ class CoursesController < ApplicationController
format.json {
render json: courses_for_user(@current_user)
}
end
end
@ -2413,6 +2415,10 @@ class CoursesController < ApplicationController
Canvas::Builders::EnrollmentDateBuilder.preload(enrollments)
enrollments_by_course = enrollments.group_by(&:course_id).values
enrollments_by_course = Api.paginate(enrollments_by_course, self, api_v1_courses_url) if api_request?
if includes.include?("teachers")
courses = enrollments_by_course.map(&:first).map(&:course)
ActiveRecord::Associations::Preloader.new(courses, :teachers).run
end
enrollments_by_course.each do |course_enrollments|
course = course_enrollments.first.course
hash << course_json(course, user, session, includes, course_enrollments)

View File

@ -0,0 +1,54 @@
define([
"./createStore",
"underscore"
], function(createStore, _) {
var { string, shape, arrayOf } = React.PropTypes;
var AccountsTreeStore = createStore({
getUrl() {
return `/api/v1/accounts/${this.context.accountId}/sub_accounts`;
},
loadTree() {
var key = this.getKey();
// fetch the account itself first, then get its subaccounts
this._load(key, `/api/v1/accounts/${this.context.accountId}`, {}, {wrap: true}).then(() => {
this.loadAll(null, true);
});
},
normalizeParams() {
return { recursive: true };
},
getTree() {
var data = this.get();
if (!data || !data.data) return [];
var accounts = [];
var idIndexMap = {};
data.data.forEach(function(item, i) {
var account = _.extend({}, item, {subAccounts: []});
accounts.push(account);
idIndexMap[account.id] = i;
})
accounts.forEach(function(account) {
var parentIdx = idIndexMap[account.parent_account_id];
if (typeof parentIdx === "undefined") return;
accounts[parentIdx].subAccounts.push(account);
});
return [accounts[0]];
}
});
AccountsTreeStore.PropType = shape({
id: string.isRequired,
parent_account_id: string,
name: string.isRequired,
subAccounts: arrayOf(
function() { AccountsTreeStore.PropType.apply(this, arguments) }
).isRequired
});
return AccountsTreeStore;
});

View File

@ -0,0 +1,50 @@
define([
"react",
"i18n!account_course_user_search",
"underscore",
"./CoursesListRow",
], function(React, I18n, _, CoursesListRow) {
var { number, string, func, shape, arrayOf } = React.PropTypes;
var CoursesList = React.createClass({
propTypes: {
courses: arrayOf(shape(CoursesListRow.propTypes)).isRequired
},
render() {
var { courses } = this.props;
return (
<div className="pad-box no-sides">
<table className="ic-Table courses-list">
<thead>
<tr>
<th />
<th>
{I18n.t("Course ID")}
</th>
<th>
{I18n.t("SIS ID")}
</th>
<th>
{I18n.t("Teacher")}
</th>
<th>
{I18n.t("Enrollments")}
</th>
<th />
</tr>
</thead>
<tbody>
{courses.map((course) => <CoursesListRow key={course.id} {...course} />)}
</tbody>
</table>
</div>
);
}
});
return CoursesList;
});

View File

@ -0,0 +1,54 @@
define([
"react",
"i18n!account_course_user_search",
"underscore",
"./UserLink",
], function(React, I18n, _, UserLink) {
var { number, string, func, shape, arrayOf } = React.PropTypes;
var CoursesListRow = React.createClass({
propTypes: {
id: string.isRequired,
name: string.isRequired,
workflow_state: string.isRequired,
total_students: number.isRequired,
teachers: arrayOf(shape(UserLink.propTypes)).isRequired
},
render() {
var { id, name, workflow_state, sis_course_id, total_students, teachers } = this.props;
var url = `/courses/${id}`;
var isPublished = workflow_state !== "unpublished";
return (
<tr>
<td style={{width: 16}}>
{isPublished && (<i className="icon-publish courses-list__published-icon" />)}
</td>
<td>
<a href={url}>{name}</a>
</td>
<td>
{sis_course_id}
</td>
<td>
{teachers &&
<div style={{margin: "-6px 0 -9px 0"}}>
{teachers.map((teacher) => <UserLink key={teacher.id} {...teacher} />)}
</div>
}
</td>
<td>
{total_students}
</td>
<td>
{/* TODO actions */}
</td>
</tr>
);
}
});
return CoursesListRow;
});

View File

@ -0,0 +1,143 @@
define([
"react",
"i18n!account_course_user_search",
"underscore",
"./CoursesStore",
"./TermsStore",
"./AccountsTreeStore",
"./CoursesList",
"./CoursesToolbar",
], function(React, I18n, _, CoursesStore, TermsStore, AccountsTreeStore, CoursesList, CoursesToolbar) {
var MIN_SEARCH_LENGTH = 3;
var stores = [CoursesStore, TermsStore, AccountsTreeStore];
var CoursesPane = React.createClass({
getInitialState() {
var filters = {
enrollment_term_id: "",
search_term: "",
with_students: false
};
return {
filters,
draftFilters: filters,
errors: {}
}
},
componentWillMount() {
stores.forEach((s) => s.addChangeListener(this.refresh));
},
componentDidMount() {
this.fetchCourses();
TermsStore.loadAll();
AccountsTreeStore.loadTree();
},
componentWillUnmount() {
stores.forEach((s) => s.removeChangeListener(this.refresh));
},
fetchCourses() {
CoursesStore.load(this.state.filters);
},
fetchMoreCourses() {
CoursesStore.loadMore(this.state.filters);
},
onUpdateFilters(newFilters) {
this.setState({
errors: {},
draftFilters: _.extend({}, this.state.draftFilters, newFilters)
});
},
onApplyFilters() {
var filters = this.state.draftFilters;
if (filters.search_term && filters.search_term.length < MIN_SEARCH_LENGTH) {
this.setState({errors: {search_term: I18n.t("Search term must be at least %{num} characters", {num: MIN_SEARCH_LENGTH})}})
} else {
this.setState({filters, errors: {}}, this.fetchCourses);
}
},
refresh() {
this.forceUpdate();
},
renderCourseMessage(courses) {
if (!courses || courses.loading) {
return (
<div className="text-center pad-box">
{I18n.t("Loading...")}
</div>
);
} else if (courses.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 (!courses.data.length) {
return (
<div className="text-center pad-box">
<div className="alert alert-info">
{I18n.t("No courses found")}
</div>
</div>
);
} else if (courses.next) {
return (
<div className="text-center pad-box">
<button
className="Button--link load_more_courses"
onClick={this.fetchMoreCourses}
>
<i className="icon-refresh" />
{" "}
{I18n.t("Load more...")}
</button>
</div>
);
}
},
render() {
var { filters, draftFilters, errors } = this.state;
var courses = CoursesStore.get(filters);
var terms = TermsStore.get();
var accounts = AccountsTreeStore.getTree();
var isLoading = !(courses && !courses.loading && terms && !terms.loading);
return (
<div>
<CoursesToolbar
onUpdateFilters={this.onUpdateFilters}
onApplyFilters={this.onApplyFilters}
terms={terms && terms.data}
accounts={accounts}
isLoading={isLoading}
{...draftFilters}
errors={errors}
/>
{courses && courses.data &&
<CoursesList courses={courses.data} />
}
{this.renderCourseMessage(courses)}
</div>
);
}
});
return CoursesPane;
});

View File

@ -0,0 +1,21 @@
define([
"./createStore",
], function(createStore) {
var CoursesStore = createStore({
getUrl() {
return `/api/v1/accounts/${this.context.accountId}/courses`;
},
normalizeParams(params) {
var payload = {};
if (params.enrollment_term_id) payload.enrollment_term_id = params.enrollment_term_id;
if (params.search_term) payload.search_term = params.search_term;
if (params.with_students) payload.enrollment_type = ["student"];
payload.include = ["total_students", "teachers"];
return payload;
}
});
return CoursesStore;
});

View File

@ -0,0 +1,117 @@
define([
"react",
"i18n!account_course_user_search",
"underscore",
"./TermsStore",
"./AccountsTreeStore",
"./NewCourseModal",
"./IcInput",
"./IcSelect",
"./IcCheckbox"
], function(React, I18n, _, TermsStore, AccountsTreeStore, NewCourseModal, IcInput, IcSelect, IcCheckbox) {
var { string, bool, func, object, arrayOf, shape } = React.PropTypes;
var CoursesToolbar = React.createClass({
propTypes: {
onUpdateFilters: func.isRequired,
onApplyFilters: func.isRequired,
isLoading: bool,
with_students: bool.isRequired,
search_term: string,
enrollment_term_id: string,
errors: object,
terms: arrayOf(TermsStore.PropType),
accounts: arrayOf(AccountsTreeStore.PropType)
},
applyFilters(e) {
e.preventDefault();
this.props.onApplyFilters();
},
renderTerms() {
var { terms } = this.props;
if (terms) {
return [
<option key="all" value="">
{I18n.t("All Terms")}
</option>
].concat(terms.map((term) => {
return (
<option key={term.id} value={term.id}>
{term.name}
</option>
);
}));
} else {
return <option value="">{I18n.t("Loading...")}</option>;
}
},
addCourse() {
this.refs.addCourse.openModal();
},
render() {
var { terms, accounts, onUpdateFilters, isLoading, with_students, search_term, enrollment_term_id, errors } = this.props;
return (
<div>
<form
className="ic-Form-group ic-Form-group--inline course_search_bar"
style={{alignItems: "center", opacity: isLoading ? 0.5 : 1}}
onSubmit={this.applyFilters}
disabled={isLoading}
>
<IcSelect
value={enrollment_term_id}
onChange={(e) => onUpdateFilters({enrollment_term_id: e.target.value})}
>
{this.renderTerms()}
</IcSelect>
<IcInput
value={search_term}
placeholder={I18n.t("Search courses...")}
onChange={(e) => onUpdateFilters({search_term: e.target.value})}
error={errors.search_term}
/>
<div className="ic-Form-control" style={{flexGrow: 0.3}}>
<button className="btn">
{I18n.t("Go")}
</button>
</div>
<IcCheckbox
controlClassName="flex-grow-2"
checked={with_students}
onChange={(e) => onUpdateFilters({with_students: e.target.checked})}
label={I18n.t("Hide courses without enrollments")}
/>
<div className="ic-Form-actions">
<button className="btn" type="button" onClick={this.addCourse}>
<i className="icon-plus" />
{" "}
{I18n.t("Course")}
</button>
</div>
</form>
<NewCourseModal
ref="addCourse"
terms={terms}
accounts={accounts}
/>
</div>
);
}
});
return CoursesToolbar;
});

View File

@ -0,0 +1,37 @@
define([
"react",
"./IcInput",
"bower/classnames/index"
], function(React, IcInput, classnames) {
var { string } = React.PropTypes;
/**
* A checkbox input wrapped with appropriate ic-Form-* elements and
* classes, with support for a label and error message.
*
* All other props are passed through to the
* <input />
*/
var IcCheckbox = React.createClass({
propTypes: {
error: string,
label: string
},
render() {
var { controlClassName } = this.props;
return (
<IcInput
{...this.props}
type="checkbox"
appendLabel={true}
noClassName={true}
controlClassName={classnames("ic-Form-control--checkbox", controlClassName)}
/>
);
}
});
return IcCheckbox;
});

View File

@ -0,0 +1,69 @@
define([
"react",
"underscore",
"bower/classnames/index"
], function(React, _, classnames) {
var { string, any, bool } = React.PropTypes;
var idCount = 0;
/**
* An input wrapped with appropriate ic-Form-* elements and classes,
* with support for a label, error message and extra classes on the
* wrapping div.
*
* All other props are passed through to the <input />
*/
var IcInput = React.createClass({
propTypes: {
error: string,
label: string,
elementType: any,
controlClassName: string,
appendLabel: bool,
noClassName: bool
},
getDefaultProps() {
return {
elementType: "input"
};
},
componentWillMount() {
this.id = `ic_input_${idCount++}`;
},
render() {
var { error, label, elementType, appendLabel, controlClassName, noClassName } = this.props;
var inputProps = _.extend({}, _.omit(this.props, ["error", "label", "elementType"]), {id: this.id});
if (elementType === "input" && !this.props.type) {
inputProps.type = "text";
}
if (!noClassName) {
inputProps.className = classnames(inputProps.className, "ic-Input");
}
var labelElement = label &&
<label htmlFor={this.id} className="ic-Label">{label}</label>;
return (
<div className={classnames("ic-Form-control", controlClassName, {"ic-Form-control--has-error": error})}>
{!!label && !appendLabel && labelElement}
{React.createElement(elementType, inputProps)}
{!!label && appendLabel && labelElement}
{!!error &&
<div className="ic-Form-message ic-Form-message--error" style={{position: "absolute"}}>
<div className="ic-Form-message__Layout">
<i className="icon-warning" role="presentation"></i>
{error}
</div>
</div>
}
</div>
);
}
});
return IcInput;
});

View File

@ -0,0 +1,32 @@
define([
"react",
"./IcInput"
], function(React, IcInput) {
var { string } = React.PropTypes;
/**
* A select wrapped with appropriate ic-Form-* elements and classes,
* with support for a label and error message.
*
* All other props (including children) are passed through to the
* <select />
*/
var IcSelect = React.createClass({
propTypes: {
error: string,
label: string
},
render() {
return (
<IcInput
{...this.props}
elementType="select"
/>
);
}
});
return IcSelect;
});

View File

@ -0,0 +1,163 @@
define([
"jquery",
"react",
"underscore",
"i18n!account_course_user_search",
"jsx/shared/modal",
"jsx/shared/modal-content",
"jsx/shared/modal-buttons",
"./CoursesStore",
"./TermsStore",
"./AccountsTreeStore",
"./IcInput",
"./IcSelect",
"compiled/jquery.rails_flash_notifications"
], function($, React, _, I18n, Modal, ModalContent, ModalButtons, CoursesStore, TermsStore, AccountsTreeStore, IcInput, IcSelect) {
var { arrayOf } = React.PropTypes;
var NewCourseModal = React.createClass({
propTypes: {
terms: arrayOf(TermsStore.PropType),
accounts: arrayOf(AccountsTreeStore.PropType)
},
getInitialState() {
return {
isOpen: false,
data: {},
errors: {}
}
},
openModal() {
this.setState({isOpen: true});
},
closeModal() {
this.setState({isOpen: false, data: {}, errors: {}});
},
onChange(field, value) {
var { data } = this.state;
var newData = {};
newData[field] = value;
data = _.extend({}, data, newData);
this.setState({ data, errors: {} });
},
onSubmit() {
var { data } = this.state;
var errors = {}
if (!data.name) errors.name = I18n.t("Course name is required");
if (!data.course_code) errors.course_code = I18n.t("Reference code is required");
if (Object.keys(errors).length) {
this.setState({ errors });
return;
}
// TODO: error handling
CoursesStore.create({course: data}).then(() => {
this.closeModal();
$.flashMessage(I18n.t("%{course_name} successfully added!", {course_name: data.name}));
});
},
renderAccountOptions(accounts, result, depth) {
accounts = accounts || this.props.accounts;
result = result || [];
depth = depth || 0;
accounts.forEach((account) => {
result.push(
<option key={account.id} value={account.id}>
{Array(2 * depth + 1).join("\u00a0") + account.name}
</option>
);
this.renderAccountOptions(account.subAccounts, result, depth + 1);
});
return result;
},
renderTermOptions() {
var { terms } = this.props;
return (terms || []).map((term) => {
return (
<option key={term.id} value={term.id}>
{term.name}
</option>
);
});
},
render() {
var { data, isOpen, errors } = this.state;
var onChange = (field) => {
return (e) => this.onChange(field, e.target.value);
};
return (
<Modal
className="ReactModal__Content--canvas ReactModal__Content--mini-modal"
ref="canvasModal"
isOpen={isOpen}
title={I18n.t("Add a New Course")}
onRequestClose={this.closeModal}
onSubmit={this.onSubmit}
>
<ModalContent>
<IcInput
label={I18n.t("Course Name")}
value={data.name}
error={errors.name}
onChange={onChange("name")}
/>
<IcInput
label={I18n.t("Reference Code")}
value={data.course_code}
error={errors.course_code}
onChange={onChange("course_code")}
/>
<IcSelect
label={I18n.t("Department")}
value={data.account_id}
onChange={onChange("account_id")}
>
{this.renderAccountOptions()}
</IcSelect>
<IcSelect
label={I18n.t("Enrollment Term")}
value={data.enrollment_term_id}
onChange={onChange("account_id")}
>
{this.renderTermOptions()}
</IcSelect>
</ModalContent>
<ModalButtons>
<button
type="button"
className="btn"
onClick={this.closeModal}
>
{I18n.t("Cancel")}
</button>
<button
type="button"
className="btn btn-primary"
onClick={this.onSubmit}
>
{I18n.t("Add Course")}
</button>
</ModalButtons>
</Modal>
);
}
});
return NewCourseModal;
});

View File

@ -0,0 +1,23 @@
define([
"react",
"./createStore",
], function(React, createStore) {
var { string, shape } = React.PropTypes;
var TermsStore = createStore({
getUrl() {
return `/api/v1/accounts/${this.context.accountId}/terms`;
},
jsonKey: "enrollment_terms"
});
TermsStore.PropType = shape({
id: string.isRequired,
name: string.isRequired
});
return TermsStore;
});

View File

@ -0,0 +1,30 @@
define([
"react",
"i18n!account_course_user_search"
], function(React, I18n) {
var UserLink = React.createClass({
propTypes: {
id: React.PropTypes.string.isRequired,
display_name: React.PropTypes.string.isRequired,
avatar_image_url: React.PropTypes.string
},
render() {
var { id, display_name, avatar_image_url } = this.props;
var url = `/users/${id}`;
return (
<div>
{!!avatar_image_url &&
<span className="ic-avatar" style={{width: 30, height: 30, margin: "-1px 10px 1px 0"}}>
<img src={avatar_image_url} />
</span>
}
<a href={url} className="user_link">{display_name}</a>
</div>
);
}
});
return UserLink;
});

View File

@ -0,0 +1,176 @@
define([
"jquery",
"jsx/shared/helpers/createStore",
"compiled/fn/parseLinkHeader",
"underscore",
"jquery.ajaxJSON"
], function($, createStore, parseLinkHeader, _) {
/**
* Build a store that support basic ajax fetching (first, next, all),
* and caches the results by params.
*
* You only need to implement getUrl, and can optionally implement
* normalizeParams and jsonKey
*/
var factory = function(spec) {
return _.extend(createStore(), {
/**
* Get a blank state in the store; useful when mounting the top-
* level component that uses the store
*
* @param {any} context
* User-defined data you can use later on in normalizeParams
* and getUrl; will be available as `this.context`
*/
reset(context) {
this.clearState();
this.context = context;
},
getKey(params) {
return JSON.stringify(params || {});
},
normalizeParams(params) {
return params;
},
getUrl() {
throw "not implemented"
},
/**
* If the API response is an object instead of an array, use this
* to specify the key containing the actual array of results
*/
jsonKey: null,
/**
* Load the first page of data for the given params
*/
load(params) {
var key = this.getKey(params);
this.lastParams = params;
var params = this.normalizeParams(params);
var url = this.getUrl();
var state = this.getState()[key] || {};
// just use what's in the cache
if (state.data) return;
this._load(key, url, params);
},
/**
* Create a record; since we're lazy, just blow away all the store
* data, but reload the last thing we fetched
*/
create(params) {
var url = this.getUrl();
return $.ajaxJSON(url, "POST", params).then(() => {
this.clearState();
if (this.lastParams)
this.load(this.lastParams);
});
},
/**
* Load the next page of data for the given params
*/
loadMore(params) {
var key = this.getKey(params);
this.lastParams = params;
var state = this.getState()[key] || {};
if (!state.next) return;
this._load(key, state.next, {}, {append: true});
},
/**
* Load data from the endpoint, following `next` links until
* everything has been fetched. Don't be dumb and call this
* on users or something :P
*/
loadAll(params, append) {
var key = this.getKey(params);
var params = this.normalizeParams(params);
this.lastParams = params;
var url = this.getUrl();
this._loadAll(key, url, params, append);
},
_loadAll(key, url, params, append) {
var promise = this._load(key, url, params, {append});
if (!promise) return;
promise.then(() => {
var state = this.getState()[key] || {};
if (state.next) {
this._loadAll(key, state.next, {}, true);
}
});
},
_load(key, url, params, options) {
options = options || {};
this.mergeState(key, {loading: true});
return $.ajaxJSON(url, "GET", params).then((data, _, xhr) => {
if (this.jsonKey) {
data = data[this.jsonKey];
}
if (options.wrap) {
data = [data];
}
if (options.append) {
data = (this.getStateFor(key).data || []).concat(data);
}
var { next } = parseLinkHeader(xhr);
this.mergeState(key, { data, next, loading: false });
}, (xhr) => {
this.mergeState(key, { error: true, loading: false });
});
},
getStateFor(key) {
return this.getState()[key] || {};
},
mergeState(key, newState) {
var state = this.getState()[key] || {};
var overallState = {};
overallState[key] = _.extend({}, state, newState);
this.setState(overallState);
},
/**
* Return whatever results we have for the given params, as well as
* useful meta data.
*
* @return {Object} obj
*
* @return {Object[]} obj.data
* The actual data
*
* @return {Boolean} obj.error
* Indication of whether there was an error
*
* @return {Boolean} obj.loading
* Whether or not we are currently fetching data
*
* @return {String} obj.next
* A URL where we can retrieve the next page of data (if
* there is more)
*/
get(params) {
var key = this.getKey(params);
return this.getState()[key];
}
}, spec);
}
return factory;
});

View File

@ -0,0 +1,96 @@
define([
"react",
"i18n!account_course_user_search",
"bower/react-tabs/dist/react-tabs",
"underscore",
"./CoursesPane",
"./CoursesStore",
"./TermsStore",
"./AccountsTreeStore"
], function(React, I18n, ReactTabs, _, CoursesPane, CoursesStore, TermsStore, AccountsTreeStore) {
var { Tab, Tabs, TabList, TabPanel } = ReactTabs;
var { string, bool, shape } = React.PropTypes;
var stores = [CoursesStore, TermsStore, AccountsTreeStore];
var App = React.createClass({
propTypes: {
accountId: string.isRequired,
permissions: shape({
theme_editor: bool.isRequired
}).isRequired
},
getInitialState() {
return {
selectedTab: 0
}
},
componentWillMount() {
stores.forEach((s) => s.reset({accountId: this.props.accountId}));
},
handleSelected(selectedTab) {
this.setState({selectedTab});
},
render() {
var { permissions, accountId } = this.props;
var tabs = [];
var panels = [];
if (permissions.can_read_course_list) {
tabs.push(<Tab>{I18n.t("Courses")}</Tab>);
panels.push(
<TabPanel>
<CoursesPane />
</TabPanel>
);
}
if (permissions.can_read_roster) {
tabs.push(<Tab>{I18n.t("People")}</Tab>);
panels.push(
<TabPanel>
TODO People Search
</TabPanel>
);
}
return (
<div>
<div className="pad-box-mini no-sides grid-row middle-xs margin-none">
<div className="col-xs-8 padding-none">
<h1>{I18n.t("Search")}</h1>
</div>
<div className="col-xs-4 padding-none align-right">
<div>
{/* TODO: figure out a way for plugins to inject stuff like
<a href="" className="btn button-group">{I18n.t("Analytics")}</a>
w/o defining them here
*/}
{
permissions.theme_editor &&
<a href={`/accounts/${accountId}/theme_editor`} className="btn button-group">{I18n.t("Theme Editor")}</a>
}
</div>
</div>
</div>
<Tabs
onSelect={this.handleSelected}
selectedIndex={this.state.selectedTab}
>
<TabList>
{tabs}
</TabList>
{panels}
</Tabs>
</div>
);
}
});
return App;
});

View File

@ -14,4 +14,6 @@ you can use one of them in your feature, please move it into this
directory and update the require paths in the other feature.
- app/jsx/theme_editor/RangeInput.jsx
- app/jsx/account_course_user_search/IcCheckbox.jsx
- app/jsx/account_course_user_search/IcInput.jsx
- app/jsx/account_course_user_search/IcSelect.jsx

View File

@ -1268,11 +1268,13 @@ class Account < ActiveRecord::Base
TAB_SIS_IMPORT = 11
TAB_GRADING_STANDARDS = 12
TAB_QUESTION_BANKS = 13
TAB_ADMIN_TOOLS = 17
TAB_SEARCH = 18
# site admin tabs
TAB_PLUGINS = 14
TAB_JOBS = 15
TAB_DEVELOPER_KEYS = 16
TAB_ADMIN_TOOLS = 17
def external_tool_tabs(opts)
tools = ContextExternalTool.active.find_all_for(self, :account_navigation)
@ -1302,8 +1304,12 @@ class Account < ActiveRecord::Base
tabs << { :id => TAB_DEVELOPER_KEYS, :label => t("#account.tab_developer_keys", "Developer Keys"), :css_class => "developer_keys", :href => :developer_keys_path, :no_args => true } if root_account? && self.grants_right?(user, :manage_developer_keys)
else
tabs = []
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)
if feature_enabled?(:course_user_search)
tabs << { :id => TAB_SEARCH, :label => t("Search"), :css_class => 'search', :href => :account_path } if user && (grants_right?(user, :read_course_list) || grants_right?(user, :read_roster))
else
tabs << { :id => TAB_COURSES, :label => t('#account.tab_courses', "Courses"), :css_class => 'courses', :href => :account_path } if user && self.grants_right?(user, :read_course_list)
tabs << { :id => TAB_USERS, :label => t('#account.tab_users', "Users"), :css_class => 'users', :href => :account_users_path } if user && self.grants_right?(user, :read_roster)
end
tabs << { :id => TAB_STATISTICS, :label => t('#account.tab_statistics', "Statistics"), :css_class => 'statistics', :href => :statistics_account_path } if user && self.grants_right?(user, :view_statistics)
tabs << { :id => TAB_PERMISSIONS, :label => t('#account.tab_permissions', "Permissions"), :css_class => 'permissions', :href => :account_permissions_path } if user && self.grants_right?(user, :manage_role_overrides)
if user && self.grants_right?(user, :manage_outcomes)

View File

@ -536,6 +536,10 @@ class Course < ActiveRecord::Base
scope :with_enrollments, -> {
where("EXISTS (?)", Enrollment.active.where("enrollments.course_id=courses.id"))
}
scope :with_enrollment_types, -> (types) {
types = types.map { |type| "#{type.capitalize}Enrollment" }
where("EXISTS (?)", Enrollment.active.where("enrollments.course_id=courses.id").where(type: types))
}
scope :without_enrollments, -> {
where("NOT EXISTS (?)", Enrollment.active.where("enrollments.course_id=courses.id"))
}

View File

@ -0,0 +1,50 @@
@import "base/environment";
.padding-none {
padding: 0;
}
.margin-none {
margin: 0;
}
.react-tabs ul[role=tablist] {
padding-left: 10px;
margin-bottom: 20px;
border-color: $ic-border-light;
li[role=tab] {
padding: 10px 25px;
font-size: 1.1em;
color: $ic-link-color;
&[selected] {
font-weight: bold;
color: $ic-color-dark;
border-color: $ic-border-light;
}
}
}
.button-group {
margin-right: 10px;
&:last-of-type {
margin-right: 0;
}
}
.courses-list td {
vertical-align: top;
}
.courses-list__published-icon {
color: $ic-color-success;
}
.ReactModalPortal .ReactModal__Content {
background: transparent;
border: none;
}
.flex-grow-2 {
flex-grow: 2;
}

View File

@ -0,0 +1,9 @@
<%
@active_tab = "search"
content_for :page_title, @account.name
add_crumb t("Search"), account_path(@account)
js_bundle :account_course_user_search
css_bundle :account_course_user_search
js_env ACCOUNT_ID: @account.id,
PERMISSIONS: @permissions
%>

View File

@ -30,6 +30,7 @@
"moment": "~2.10.6",
"reflux": "~0.2.7",
"axios": "~0.5.4",
"when": "~3.7.3"
"when": "~3.7.3",
"react-tabs": "https://github.com/rackt/react-tabs.git#1ad1031ec4a50784225a05611c4959d15745fc3d"
}
}

View File

@ -21,6 +21,7 @@ module Api::V1::Course
include Api::V1::EnrollmentTerm
include Api::V1::SectionEnrollments
include Api::V1::PostGradesStatus
include Api::V1::User
def course_settings_json(course)
settings = {}
@ -84,6 +85,7 @@ module Api::V1::Course
hash['total_students'] = course.students.count if includes.include?('total_students')
hash['passback_status'] = post_grades_status_json(course) if includes.include?('passback_status')
hash['is_favorite'] = course.favorite_for_user?(user) if includes.include?('favorites')
hash['teachers'] = course.teachers.map { |teacher| user_display_json(teacher) } if includes.include?('teachers')
add_helper_dependant_entries(hash, course, builder)
apply_nickname(hash, course, user) if user
end

View File

@ -70,7 +70,11 @@ module Canvas
end
def map
@map ||= Canvas::RequireJs::ClientAppExtension.map.to_json
@map ||= Canvas::RequireJs::ClientAppExtension.map.merge({
'*' => {
React: "react" # for misbehaving UMD like react-tabs
}
}).to_json
end
def bundles

View File

@ -404,6 +404,14 @@ END
applies_to: 'Course',
state: 'allowed',
root_opt_in: true
},
'course_user_search' => {
display_name: -> { I18n.t('Course and User Search') },
description: -> { I18n.t('Updated UI for searching and displaying users and courses within an account.') },
applies_to: 'Account',
state: 'hidden',
development: true,
root_opt_in: true
}
)

View File

@ -0,0 +1,31 @@
{
"name": "react-tabs",
"homepage": "https://github.com/rackt/react-tabs",
"authors": [
"Matt Zabriskie"
],
"description": "React tabs component",
"main": "./dist/react-tabs.js",
"keywords": [
"react",
"tabs"
],
"license": "MIT",
"ignore": [
"**/.*",
"build",
"examples",
"lib",
"node_modules",
"specs",
"package.json"
],
"_release": "1ad1031ec4",
"_resolution": {
"type": "commit",
"commit": "1ad1031ec4a50784225a05611c4959d15745fc3d"
},
"_source": "https://github.com/rackt/react-tabs.git",
"_target": "1ad1031ec4a50784225a05611c4959d15745fc3d",
"_originalSource": "https://github.com/rackt/react-tabs.git"
}

View File

@ -0,0 +1,50 @@
v0.4.1 - Wed, 09 Sep 2015 19:18:50 GMT
--------------------------------------
-
v0.4.0 - Tue, 18 Aug 2015 22:53:59 GMT
--------------------------------------
-
v0.3.0 - Tue, 11 Aug 2015 00:42:23 GMT
--------------------------------------
- [0eb43e5](../../commit/0eb43e5) [added] Support for disabling tabs
v0.2.1 - Fri, 26 Jun 2015 19:35:50 GMT
--------------------------------------
- [5132966](../../commit/5132966) [added] Bower support closes #22
- [3f43e89](../../commit/3f43e89) [fixed] Issue with React being included twice closes #23
# Changelog
### 0.1.0 (Jul 18, 2014)
- Initial release
### 0.1.1 (Jul 19, 2014)
- Fixing warning: Invalid access to component property
- Fixing style weirdness in Firefox
### 0.1.2 (Jul 23, 2014)
- Making Tab and TabPanel to be stateless
- Throwing Error when Tab count and TabPanel count aren't equal
### 0.2.0 (Jun 07, 2015)
- Allowing children of Tab to select Tab ([#9](https://github.com/rackt/react-tabs/pull/9))
- Only render the selected TabPanel
- Upgrading to React 0.13
- Removing JSX
- Fixing issue with focus management ([#7](https://github.com/rackt/react-tabs/pull/7))
- Fixing issue caused by no children being provided ([#6](https://github.com/rackt/react-tabs/issues/6))
- Fixing issue that made dynamic Tabs difficult

View File

@ -0,0 +1,19 @@
Copyright (c) 2015 by Matt Zabriskie
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@ -0,0 +1,108 @@
# react-tabs [![Build Status](https://travis-ci.org/rackt/react-tabs.svg?branch=master)](https://travis-ci.org/rackt/react-tabs)
React tabs component
## Installing
```bash
$ npm install react-tabs
```
## Demo
http://rackt.github.io/react-tabs/example/
## Example
```js
/** @jsx React.DOM */
var React = require('react');
var ReactTabs = require('react-tabs');
var Tab = ReactTabs.Tab;
var Tabs = ReactTabs.Tabs;
var TabList = ReactTabs.TabList;
var TabPanel = ReactTabs.TabPanel;
var App = React.createClass({
handleSelect: function (index, last) {
console.log('Selected tab: ' + index + ', Last tab: ' + last);
},
render: function () {
return (
{/*
<Tabs/> is a composite component and acts as the main container.
`onSelect` is called whenever a tab is selected. The handler for
this function will be passed the current index as well as the last index.
`selectedIndex` is the tab to select when first rendered. By default
the first (index 0) tab will be selected.
`forceRenderTabPanel` By default this react-tabs will only render the selected
tab's contents. Setting `forceRenderTabPanel` to `true` allows you to override the
default behavior, which may be useful in some circumstances (such as animating between tabs).
*/}
<Tabs
onSelect={this.handleSelected}
selectedIndex={2}
>
{/*
<TabList/> is a composit component and is the container for the <Tab/>s.
*/}
<TabList>
{/*
<Tab/> is the actual tab component that users will interact with.
Selecting a tab can be done by either clicking with the mouse,
or by using the keyboard tab to give focus then navigating with
the arrow keys (right/down to select tab to the right of selected,
left/up to select tab to the left of selected).
The content of the <Tab/> (this.props.children) will be shown as the label.
*/}
<Tab>Foo</Tab>
<Tab>Bar</Tab>
<Tab>Baz</Tab>
</TabList>
{/*
<TabPanel/> is the content for the tab.
There should be an equal number of <Tab/> and <TabPanel/> components.
<Tab/> and <TabPanel/> components are tied together by the order in
which they appear. The first (index 0) <Tab/> will be associated with
the <TabPanel/> of the same index. Running this example when
`selectedIndex` is 0 the tab with the label "Foo" will be selected
and the content shown will be "Hello from Foo".
As with <Tab/> the content of <TabPanel/> will be shown as the content.
*/}
<TabPanel>
<h2>Hello from Foo</h2>
</TabPanel>
<TabPanel>
<h2>Hello from Bar</h2>
</TabPanel>
<TabPanel>
<h2>Hello from Baz</h2>
</TabPanel>
</Tabs>
);
}
});
React.render(<App/>, document.getElementById('container'));
```
## License
MIT

View File

@ -0,0 +1,24 @@
{
"name": "react-tabs",
"version": "0.4.1",
"homepage": "https://github.com/rackt/react-tabs",
"authors": [
"Matt Zabriskie"
],
"description": "React tabs component",
"main": "./dist/react-tabs.js",
"keywords": [
"react",
"tabs"
],
"license": "MIT",
"ignore": [
"**/.*",
"build",
"examples",
"lib",
"node_modules",
"specs",
"package.json"
]
}

View File

@ -0,0 +1,716 @@
(function webpackUniversalModuleDefinition(root, factory) {
if(typeof exports === 'object' && typeof module === 'object')
module.exports = factory(require("React"));
else if(typeof define === 'function' && define.amd)
define(["React"], factory);
else if(typeof exports === 'object')
exports["ReactTabs"] = factory(require("React"));
else
root["ReactTabs"] = factory(root["React"]);
})(this, function(__WEBPACK_EXTERNAL_MODULE_2__) {
return /******/ (function(modules) { // webpackBootstrap
/******/ // The module cache
/******/ var installedModules = {};
/******/
/******/ // The require function
/******/ function __webpack_require__(moduleId) {
/******/
/******/ // Check if module is in cache
/******/ if(installedModules[moduleId])
/******/ return installedModules[moduleId].exports;
/******/
/******/ // Create a new module (and put it into the cache)
/******/ var module = installedModules[moduleId] = {
/******/ exports: {},
/******/ id: moduleId,
/******/ loaded: false
/******/ };
/******/
/******/ // Execute the module function
/******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
/******/
/******/ // Flag the module as loaded
/******/ module.loaded = true;
/******/
/******/ // Return the exports of the module
/******/ return module.exports;
/******/ }
/******/
/******/
/******/ // expose the modules object (__webpack_modules__)
/******/ __webpack_require__.m = modules;
/******/
/******/ // expose the module cache
/******/ __webpack_require__.c = installedModules;
/******/
/******/ // __webpack_public_path__
/******/ __webpack_require__.p = "";
/******/
/******/ // Load entry module and return exports
/******/ return __webpack_require__(0);
/******/ })
/************************************************************************/
/******/ ([
/* 0 */
/***/ function(module, exports, __webpack_require__) {
'use strict';
module.exports = {
Tabs: __webpack_require__(1),
TabList: __webpack_require__(7),
Tab: __webpack_require__(6),
TabPanel: __webpack_require__(9)
};
/***/ },
/* 1 */
/***/ function(module, exports, __webpack_require__) {
/* eslint indent:0 */
'use strict';
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { 'default': obj }; }
var _reactAddons = __webpack_require__(2);
var _reactAddons2 = _interopRequireDefault(_reactAddons);
var _jsStylesheet = __webpack_require__(3);
var _jsStylesheet2 = _interopRequireDefault(_jsStylesheet);
var _helpersUuid = __webpack_require__(4);
var _helpersUuid2 = _interopRequireDefault(_helpersUuid);
var _helpersChildrenPropType = __webpack_require__(5);
var _helpersChildrenPropType2 = _interopRequireDefault(_helpersChildrenPropType);
// Determine if a node from event.target is a Tab element
function isTabNode(node) {
return node.nodeName === 'LI' && node.getAttribute('role') === 'tab';
}
// Determine if a tab node is disabled
function isTabDisabled(node) {
return node.getAttribute('aria-disabled') === 'true';
}
module.exports = _reactAddons2['default'].createClass({
displayName: 'Tabs',
propTypes: {
selectedIndex: _reactAddons.PropTypes.number,
onSelect: _reactAddons.PropTypes.func,
focus: _reactAddons.PropTypes.bool,
children: _helpersChildrenPropType2['default'],
forceRenderTabPanel: _reactAddons.PropTypes.bool
},
childContextTypes: {
forceRenderTabPanel: _reactAddons.PropTypes.bool
},
getDefaultProps: function getDefaultProps() {
return {
selectedIndex: -1,
focus: false,
forceRenderTabPanel: false
};
},
getInitialState: function getInitialState() {
return this.copyPropsToState(this.props);
},
getChildContext: function getChildContext() {
return {
forceRenderTabPanel: this.props.forceRenderTabPanel
};
},
componentWillMount: function componentWillMount() {
(0, _jsStylesheet2['default'])(__webpack_require__(8));
},
componentWillReceiveProps: function componentWillReceiveProps(newProps) {
this.setState(this.copyPropsToState(newProps));
},
handleClick: function handleClick(e) {
var node = e.target;
do {
if (isTabNode(node)) {
if (isTabDisabled(node)) {
return;
}
var index = [].slice.call(node.parentNode.children).indexOf(node);
this.setSelected(index);
return;
}
} while ((node = node.parentNode) !== null);
},
handleKeyDown: function handleKeyDown(e) {
if (isTabNode(e.target)) {
var index = this.state.selectedIndex;
var preventDefault = false;
// Select next tab to the left
if (e.keyCode === 37 || e.keyCode === 38) {
index = this.getPrevTab(index);
preventDefault = true;
}
// Select next tab to the right
/* eslint brace-style:0 */
else if (e.keyCode === 39 || e.keyCode === 40) {
index = this.getNextTab(index);
preventDefault = true;
}
// This prevents scrollbars from moving around
if (preventDefault) {
e.preventDefault();
}
this.setSelected(index, true);
}
},
setSelected: function setSelected(index, focus) {
// Don't do anything if nothing has changed
if (index === this.state.selectedIndex) return;
// Check index boundary
if (index < 0 || index >= this.getTabsCount()) return;
// Keep reference to last index for event handler
var last = this.state.selectedIndex;
// Update selected index
this.setState({ selectedIndex: index, focus: focus === true });
// Call change event handler
if (typeof this.props.onSelect === 'function') {
this.props.onSelect(index, last);
}
},
getNextTab: function getNextTab(index) {
var count = this.getTabsCount();
// Look for non-disabled tab from index to the last tab on the right
for (var i = index + 1; i < count; i++) {
var tab = this.getTab(i);
if (!isTabDisabled(tab.getDOMNode())) {
return i;
}
}
// If no tab found, continue searching from first on left to index
for (var i = 0; i < index; i++) {
var tab = this.getTab(i);
if (!isTabDisabled(tab.getDOMNode())) {
return i;
}
}
// No tabs are disabled, return index
return index;
},
getPrevTab: function getPrevTab(index) {
var i = index;
// Look for non-disabled tab from index to first tab on the left
while (i--) {
var tab = this.getTab(i);
if (!isTabDisabled(tab.getDOMNode())) {
return i;
}
}
// If no tab found, continue searching from last tab on right to index
i = this.getTabsCount();
while (i-- > index) {
var tab = this.getTab(i);
if (!isTabDisabled(tab.getDOMNode())) {
return i;
}
}
// No tabs are disabled, return index
return index;
},
getTabsCount: function getTabsCount() {
return this.props.children && this.props.children[0] ? _reactAddons2['default'].Children.count(this.props.children[0].props.children) : 0;
},
getPanelsCount: function getPanelsCount() {
return _reactAddons2['default'].Children.count(this.props.children.slice(1));
},
getTabList: function getTabList() {
return this.refs.tablist;
},
getTab: function getTab(index) {
return this.refs['tabs-' + index];
},
getPanel: function getPanel(index) {
return this.refs['panels-' + index];
},
getChildren: function getChildren() {
var index = 0;
var count = 0;
var children = this.props.children;
var state = this.state;
var tabIds = this.tabIds = this.tabIds || [];
var panelIds = this.panelIds = this.panelIds || [];
var diff = this.tabIds.length - this.getTabsCount();
// Add ids if new tabs have been added
// Don't bother removing ids, just keep them in case they are added again
// This is more efficient, and keeps the uuid counter under control
while (diff++ < 0) {
tabIds.push((0, _helpersUuid2['default'])());
panelIds.push((0, _helpersUuid2['default'])());
}
// Map children to dynamically setup refs
return _reactAddons2['default'].Children.map(children, function (child) {
var result = null;
// Clone TabList and Tab components to have refs
if (count++ === 0) {
// TODO try setting the uuid in the "constructor" for `Tab`/`TabPanel`
result = _reactAddons2['default'].addons.cloneWithProps(child, {
ref: 'tablist',
children: _reactAddons2['default'].Children.map(child.props.children, function (tab) {
var ref = 'tabs-' + index;
var id = tabIds[index];
var panelId = panelIds[index];
var selected = state.selectedIndex === index;
var focus = selected && state.focus;
index++;
return _reactAddons2['default'].addons.cloneWithProps(tab, {
ref: ref,
id: id,
panelId: panelId,
selected: selected,
focus: focus
});
})
});
// Reset index for panels
index = 0;
}
// Clone TabPanel components to have refs
else {
var ref = 'panels-' + index;
var id = panelIds[index];
var tabId = tabIds[index];
var selected = state.selectedIndex === index;
index++;
result = _reactAddons2['default'].addons.cloneWithProps(child, {
ref: ref,
id: id,
tabId: tabId,
selected: selected
});
}
return result;
});
},
render: function render() {
var _this = this;
// This fixes an issue with focus management.
//
// Ultimately, when focus is true, and an input has focus,
// and any change on that input causes a state change/re-render,
// focus gets sent back to the active tab, and input loses focus.
//
// Since the focus state only needs to be remembered
// for the current render, we can reset it once the
// render has happened.
//
// Don't use setState, because we don't want to re-render.
//
// See https://github.com/mzabriskie/react-tabs/pull/7
if (this.state.focus) {
setTimeout(function () {
_this.state.focus = false;
}, 0);
}
return _reactAddons2['default'].createElement(
'div',
{
className: 'react-tabs',
onClick: this.handleClick,
onKeyDown: this.handleKeyDown
},
this.getChildren()
);
},
// This is an anti-pattern, so sue me
copyPropsToState: function copyPropsToState(props) {
var selectedIndex = props.selectedIndex;
// If no selectedIndex prop was supplied, then try
// preserving the existing selectedIndex from state.
// If the state has not selectedIndex, default
// to the first tab in the TabList.
//
// TODO: Need automation testing around this
// Manual testing can be done using examples/focus
// See 'should preserve selectedIndex when typing' in specs/Tabs.spec.js
if (selectedIndex === -1) {
if (this.state && this.state.selectedIndex) {
selectedIndex = this.state.selectedIndex;
} else {
selectedIndex = 0;
}
}
return {
selectedIndex: selectedIndex,
focus: props.focus
};
}
});
/***/ },
/* 2 */
/***/ function(module, exports) {
module.exports = __WEBPACK_EXTERNAL_MODULE_2__;
/***/ },
/* 3 */
/***/ function(module, exports, __webpack_require__) {
!(function() {
function jss(blocks) {
var css = [];
for (var block in blocks)
css.push(createStyleBlock(block, blocks[block]));
injectCSS(css);
}
function createStyleBlock(selector, rules) {
return selector + ' {\n' + parseRules(rules) + '\n}';
}
function parseRules(rules) {
var css = [];
for (var rule in rules)
css.push(' '+rule+': '+rules[rule]+';');
return css.join('\n');
}
function injectCSS(css) {
var style = document.getElementById('jss-styles');
if (!style) {
style = document.createElement('style');
style.setAttribute('id', 'jss-styles');
var head = document.getElementsByTagName('head')[0];
head.insertBefore(style, head.firstChild);
}
var node = document.createTextNode(css.join('\n\n'));
style.appendChild(node);
}
if (true)
module.exports = jss;
else
window.jss = jss;
})();
/***/ },
/* 4 */
/***/ function(module, exports) {
// Get a universally unique identifier
'use strict';
var count = 0;
module.exports = function uuid() {
return 'react-tabs-' + count++;
};
/***/ },
/* 5 */
/***/ function(module, exports, __webpack_require__) {
'use strict';
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { 'default': obj }; }
var _react = __webpack_require__(2);
var _react2 = _interopRequireDefault(_react);
var _componentsTab = __webpack_require__(6);
var _componentsTab2 = _interopRequireDefault(_componentsTab);
var _componentsTabList = __webpack_require__(7);
var _componentsTabList2 = _interopRequireDefault(_componentsTabList);
module.exports = function childrenPropTypes(props, propName) {
var error = undefined;
var tabsCount = 0;
var panelsCount = 0;
var children = props[propName];
_react2['default'].Children.forEach(children, function (child) {
if (child.type === _componentsTabList2['default']) {
_react2['default'].Children.forEach(child.props.children, function (c) {
if (c.type === _componentsTab2['default']) {
tabsCount++;
} else {
error = new Error('Expected `Tab` but found `' + (c.type.displayName || c.type) + '`');
}
});
} else if (child.type.displayName === 'TabPanel') {
panelsCount++;
} else {
error = new Error('Expected `TabList` or `TabPanel` but found `' + (child.type.displayName || child.type) + '`');
}
});
if (tabsCount !== panelsCount) {
error = new Error('There should be an equal number of `Tabs` and `TabPanels`. ' + 'Received ' + tabsCount + ' `Tabs` and ' + panelsCount + ' `TabPanels`.');
}
return error;
};
/***/ },
/* 6 */
/***/ function(module, exports, __webpack_require__) {
/* eslint indent:0 */
'use strict';
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { 'default': obj }; }
var _react = __webpack_require__(2);
var _react2 = _interopRequireDefault(_react);
function syncNodeAttributes(node, props) {
if (props.selected) {
node.setAttribute('tabindex', 0);
node.setAttribute('selected', 'selected');
if (props.focus) {
node.focus();
}
} else {
node.removeAttribute('tabindex');
node.removeAttribute('selected');
}
}
module.exports = _react2['default'].createClass({
displayName: 'Tab',
propTypes: {
id: _react.PropTypes.string,
selected: _react.PropTypes.bool,
disabled: _react.PropTypes.bool,
panelId: _react.PropTypes.string,
children: _react.PropTypes.oneOfType([_react.PropTypes.array, _react.PropTypes.object, _react.PropTypes.string])
},
getDefaultProps: function getDefaultProps() {
return {
focus: false,
selected: false,
id: null,
panelId: null
};
},
componentDidMount: function componentDidMount() {
syncNodeAttributes(this.getDOMNode(), this.props);
},
componentDidUpdate: function componentDidUpdate() {
syncNodeAttributes(this.getDOMNode(), this.props);
},
render: function render() {
return _react2['default'].createElement(
'li',
{
role: 'tab',
id: this.props.id,
'aria-selected': this.props.selected ? 'true' : 'false',
'aria-expanded': this.props.selected ? 'true' : 'false',
'aria-disabled': this.props.disabled ? 'true' : 'false',
'aria-controls': this.props.panelId
},
this.props.children
);
}
});
/***/ },
/* 7 */
/***/ function(module, exports, __webpack_require__) {
/* eslint indent:0 */
'use strict';
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { 'default': obj }; }
var _react = __webpack_require__(2);
var _react2 = _interopRequireDefault(_react);
module.exports = _react2['default'].createClass({
displayName: 'TabList',
propTypes: {
children: _react.PropTypes.oneOfType([_react.PropTypes.object, _react.PropTypes.array])
},
render: function render() {
return _react2['default'].createElement(
'ul',
{ role: 'tablist' },
this.props.children
);
}
});
/***/ },
/* 8 */
/***/ function(module, exports) {
'use strict';
module.exports = {
'.react-tabs [role=tablist]': {
'border-bottom': '1px solid #aaa',
'margin': '0 0 10px',
'padding': '0'
},
'.react-tabs [role=tab]': {
'display': 'inline-block',
'border': '1px solid transparent',
'border-bottom': 'none',
'bottom': '-1px',
'position': 'relative',
'list-style': 'none',
'padding': '6px 12px',
'cursor': 'pointer'
},
'.react-tabs [role=tab][aria-selected=true]': {
'background': '#fff',
'border-color': '#aaa',
'border-radius': '5px 5px 0 0',
'-moz-border-radius': '5px 5px 0 0',
'-webkit-border-radius': '5px 5px 0 0'
},
'.react-tabs [role=tab][aria-disabled=true]': {
'color': 'GrayText',
'cursor': 'default'
},
'.react-tabs [role=tab]:focus': {
'box-shadow': '0 0 5px hsl(208, 99%, 50%)',
'border-color': 'hsl(208, 99%, 50%)',
'outline': 'none'
},
'.react-tabs [role=tab]:focus:after': {
'content': '""',
'position': 'absolute',
'height': '5px',
'left': '-4px',
'right': '-4px',
'bottom': '-5px',
'background': '#fff'
}
};
/***/ },
/* 9 */
/***/ function(module, exports, __webpack_require__) {
/* eslint indent:0 */
'use strict';
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { 'default': obj }; }
var _react = __webpack_require__(2);
var _react2 = _interopRequireDefault(_react);
module.exports = _react2['default'].createClass({
displayName: 'TabPanel',
propTypes: {
selected: _react.PropTypes.bool,
id: _react.PropTypes.string,
tabId: _react.PropTypes.string,
children: _react.PropTypes.oneOfType([_react.PropTypes.array, _react.PropTypes.object, _react.PropTypes.string])
},
contextTypes: {
forceRenderTabPanel: _react.PropTypes.bool
},
getDefaultProps: function getDefaultProps() {
return {
selected: false,
id: null,
tabId: null
};
},
render: function render() {
var children = this.context.forceRenderTabPanel || this.props.selected ? this.props.children : null;
return _react2['default'].createElement(
'div',
{
role: 'tabpanel',
id: this.props.id,
'aria-labeledby': this.props.tabId,
style: { display: this.props.selected ? null : 'none' }
},
children
);
}
});
/***/ }
/******/ ])
});
;
//# sourceMappingURL=react-tabs.js.map

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,109 @@
require File.expand_path(File.dirname(__FILE__) + '/../common')
describe "new account course search" do
include_context "in-process server selenium tests"
before :once do
account_model
@account.enable_feature!(:course_user_search)
account_admin_user(:account => @account, :active_all => true)
end
before do
user_session(@user)
end
def get_rows
ff('.courses-list > tbody > tr')
end
it "should not show the courses tab without permission" do
@account.role_overrides.create! :role => admin_role, :permission => 'read_course_list', :enabled => false
get "/accounts/#{@account.id}"
expect(".react-tabs > ul").to_not include_text("Courses")
end
it "should hide courses without enrollments if checked" do
empty_course = course(:account => @account, :course_name => "no enrollments")
not_empty_course = course(:account => @account, :course_name => "yess enrollments", :active_all => true)
student_in_course(:course => not_empty_course, :active_all => true)
get "/accounts/#{@account.id}"
expect(get_rows.count).to eq 2
f('.course_search_bar input[type=checkbox]').click # hide anymore
f('.course_search_bar button').click
wait_for_ajaximations
rows = get_rows
expect(rows.count).to eq 1
expect(rows.first).to include_text(not_empty_course.name)
expect(rows.first).to_not include_text(empty_course.name)
end
it "should paginate" do
11.times do |x|
course(:account => @account, :course_name => "course #{x + 1}")
end
get "/accounts/#{@account.id}"
expect(get_rows.count).to eq 10
f(".load_more_courses").click
wait_for_ajaximations
expect(get_rows.count).to eq 11
expect(f(".load_more_courses")).to be_nil
end
it "should search by term" do
term = @account.enrollment_terms.create!(:name => "some term")
term_course = course(:account => @account, :course_name => "term course")
term_course.enrollment_term = term
term_course.save!
other_course = course(:account => @account, :course_name => "other course")
get "/accounts/#{@account.id}"
click_option(".course_search_bar select", term.name)
f('.course_search_bar button').click
wait_for_ajaximations
rows = get_rows
expect(rows.count).to eq 1
expect(rows.first).to include_text(term_course.name)
end
it "should search by name" do
match_course = course(:account => @account, :course_name => "course with a search term")
not_match_course = course(:account => @account, :course_name => "diffrient cuorse")
get "/accounts/#{@account.id}"
f('.course_search_bar input[type=text]').send_keys('search')
f('.course_search_bar button').click
wait_for_ajaximations
rows = get_rows
expect(rows.count).to eq 1
expect(rows.first).to include_text(match_course.name)
end
it "should show teachers" do
course(:account => @account)
user(:name => "some teacher")
teacher_in_course(:course => @course, :user => @user)
get "/accounts/#{@account.id}"
user_link = get_rows.first.find("a.user_link")
expect(user_link).to include_text(@user.name)
expect(user_link['href']).to eq user_url(@user)
end
end