diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py
index 2f61345f9afa..38f1abd94b71 100644
--- a/cms/djangoapps/contentstore/utils.py
+++ b/cms/djangoapps/contentstore/utils.py
@@ -293,6 +293,13 @@ def reverse_course_url(handler_name, course_key, kwargs=None):
return reverse_url(handler_name, 'course_key_string', course_key, kwargs)
+def reverse_library_url(handler_name, library_key, kwargs=None):
+ """
+ Creates the URL for handlers that use library_keys as URL parameters.
+ """
+ return reverse_url(handler_name, 'library_key_string', library_key, kwargs)
+
+
def reverse_usage_url(handler_name, usage_key, kwargs=None):
"""
Creates the URL for handlers that use usage_keys as URL parameters.
diff --git a/cms/djangoapps/contentstore/views/__init__.py b/cms/djangoapps/contentstore/views/__init__.py
index 5e644468fdb3..9e2e1e1828b9 100644
--- a/cms/djangoapps/contentstore/views/__init__.py
+++ b/cms/djangoapps/contentstore/views/__init__.py
@@ -12,6 +12,7 @@
from .helpers import *
from .item import *
from .import_export import *
+from .library import *
from .preview import *
from .public import *
from .export_git import *
diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py
index 468d0efb5b40..c2b435db64ff 100644
--- a/cms/djangoapps/contentstore/views/course.py
+++ b/cms/djangoapps/contentstore/views/course.py
@@ -38,6 +38,7 @@
add_extra_panel_tab,
remove_extra_panel_tab,
reverse_course_url,
+ reverse_library_url,
reverse_usage_url,
reverse_url,
remove_all_instructors,
@@ -56,6 +57,7 @@
ADVANCED_COMPONENT_TYPES,
)
from contentstore.tasks import rerun_course
+from .library import LIBRARIES_ENABLED
from .item import create_xblock_info
from course_creators.views import get_course_creator_status, add_user_with_status_unrequested
from contentstore import utils
@@ -340,6 +342,14 @@ def _accessible_courses_list_from_groups(request):
return courses_list.values(), in_process_course_actions
+def _accessible_libraries_list(user):
+ """
+ List all libraries available to the logged in user by iterating through all libraries
+ """
+ # No need to worry about ErrorDescriptors - split's get_libraries() never returns them.
+ return [lib for lib in modulestore().get_libraries() if has_course_access(user, lib.location)]
+
+
@login_required
@ensure_csrf_cookie
def course_listing(request):
@@ -359,6 +369,8 @@ def course_listing(request):
# so fallback to iterating through all courses
courses, in_process_course_actions = _accessible_courses_list(request)
+ libraries = _accessible_libraries_list(request.user) if LIBRARIES_ENABLED else []
+
def format_course_for_view(course):
"""
Return a dict of the data which the view requires for each course
@@ -392,6 +404,18 @@ def format_in_process_course_view(uca):
}) if uca.state == CourseRerunUIStateManager.State.FAILED else ''
}
+ def format_library_for_view(library):
+ """
+ Return a dict of the data which the view requires for each library
+ """
+ return {
+ 'display_name': library.display_name,
+ 'library_key': unicode(library.location.library_key),
+ 'url': reverse_library_url('library_handler', unicode(library.location.library_key)),
+ 'org': library.display_org_with_default,
+ 'number': library.display_number_with_default,
+ }
+
# remove any courses in courses that are also in the in_process_course_actions list
in_process_action_course_keys = [uca.course_key for uca in in_process_course_actions]
courses = [
@@ -405,6 +429,7 @@ def format_in_process_course_view(uca):
return render_to_response('index.html', {
'courses': courses,
'in_process_course_actions': in_process_course_actions,
+ 'libraries': [format_library_for_view(lib) for lib in libraries],
'user': request.user,
'request_course_creator_url': reverse('contentstore.views.request_course_creator'),
'course_creator_status': _get_course_creator_status(request.user),
diff --git a/cms/djangoapps/contentstore/views/helpers.py b/cms/djangoapps/contentstore/views/helpers.py
index 34ef869f170f..3769c81978fd 100644
--- a/cms/djangoapps/contentstore/views/helpers.py
+++ b/cms/djangoapps/contentstore/views/helpers.py
@@ -13,7 +13,7 @@
from edxmako.shortcuts import render_to_string, render_to_response
from xblock.core import XBlock
from xmodule.modulestore.django import modulestore
-from contentstore.utils import reverse_course_url, reverse_usage_url
+from contentstore.utils import reverse_course_url, reverse_library_url, reverse_usage_url
__all__ = ['edge', 'event', 'landing']
@@ -106,6 +106,9 @@ def xblock_studio_url(xblock, parent_xblock=None):
url=reverse_course_url('course_handler', xblock.location.course_key),
usage_key=urllib.quote(unicode(xblock.location))
)
+ elif category == 'library':
+ library_key = xblock.location.course_key
+ return reverse_library_url('library_handler', library_key)
else:
return reverse_usage_url('container_handler', xblock.location)
diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py
index 132bcbff6c29..64c743a7dd17 100644
--- a/cms/djangoapps/contentstore/views/item.py
+++ b/cms/djangoapps/contentstore/views/item.py
@@ -47,6 +47,7 @@
from models.settings.course_grading import CourseGradingModel
from cms.lib.xblock.runtime import handler_url, local_resource_url
from opaque_keys.edx.keys import UsageKey, CourseKey
+from opaque_keys.edx.locator import LibraryUsageLocator
__all__ = ['orphan_handler', 'xblock_handler', 'xblock_view_handler', 'xblock_outline_handler']
@@ -650,7 +651,9 @@ def _get_module_info(xblock, rewrite_static_links=True):
)
# Pre-cache has changes for the entire course because we'll need it for the ancestor info
- modulestore().has_changes(modulestore().get_course(xblock.location.course_key, depth=None))
+ # Except library blocks which don't use draft/publish
+ if not isinstance(xblock.location, LibraryUsageLocator):
+ modulestore().has_changes(modulestore().get_courselike(xblock.location.course_key, depth=None))
# Note that children aren't being returned until we have a use case.
return create_xblock_info(xblock, data=data, metadata=own_metadata(xblock), include_ancestor_info=True)
@@ -691,12 +694,16 @@ def safe_get_username(user_id):
return None
+ is_library_block = isinstance(xblock.location, LibraryUsageLocator)
is_xblock_unit = is_unit(xblock, parent_xblock)
- # this should not be calculated for Sections and Subsections on Unit page
- has_changes = modulestore().has_changes(xblock) if (is_xblock_unit or course_outline) else None
+ # this should not be calculated for Sections and Subsections on Unit page or for library blocks
+ has_changes = modulestore().has_changes(xblock) if (is_xblock_unit or course_outline) and not is_library_block else None
if graders is None:
- graders = CourseGradingModel.fetch(xblock.location.course_key).graders
+ if xblock.category != "library":
+ graders = CourseGradingModel.fetch(xblock.location.course_key).graders
+ else:
+ graders = []
# Compute the child info first so it can be included in aggregate information for the parent
should_visit_children = include_child_info and (course_outline and not is_xblock_unit or not course_outline)
@@ -716,7 +723,7 @@ def safe_get_username(user_id):
visibility_state = _compute_visibility_state(xblock, child_info, is_xblock_unit and has_changes)
else:
visibility_state = None
- published = modulestore().has_published_version(xblock)
+ published = modulestore().has_published_version(xblock) if not is_library_block else None
xblock_info = {
"id": unicode(xblock.location),
@@ -724,7 +731,7 @@ def safe_get_username(user_id):
"category": xblock.category,
"edited_on": get_default_time_display(xblock.subtree_edited_on) if xblock.subtree_edited_on else None,
"published": published,
- "published_on": get_default_time_display(xblock.published_on) if xblock.published_on else None,
+ "published_on": get_default_time_display(xblock.published_on) if published and xblock.published_on else None,
"studio_url": xblock_studio_url(xblock, parent_xblock),
"released_to_students": datetime.now(UTC) > xblock.start,
"release_date": release_date,
diff --git a/cms/djangoapps/contentstore/views/library.py b/cms/djangoapps/contentstore/views/library.py
new file mode 100644
index 000000000000..846673bde40f
--- /dev/null
+++ b/cms/djangoapps/contentstore/views/library.py
@@ -0,0 +1,169 @@
+"""
+Views related to content libraries.
+A content library is a structure containing XBlocks which can be re-used in the
+multiple courses.
+"""
+from __future__ import absolute_import
+
+import json
+import logging
+
+from contentstore.views.item import create_xblock_info
+from contentstore.utils import reverse_library_url
+from django.http import HttpResponseBadRequest, HttpResponseNotAllowed, Http404
+from django.contrib.auth.decorators import login_required
+from django.core.exceptions import PermissionDenied
+from django.conf import settings
+from django.utils.translation import ugettext as _
+from django_future.csrf import ensure_csrf_cookie
+from edxmako.shortcuts import render_to_response
+from opaque_keys import InvalidKeyError
+from opaque_keys.edx.keys import CourseKey
+from opaque_keys.edx.locator import LibraryLocator, LibraryUsageLocator
+from xmodule.library_module import LibraryDescriptor
+from xmodule.modulestore.exceptions import ItemNotFoundError, DuplicateCourseError
+from xmodule.modulestore import ModuleStoreEnum
+from xmodule.modulestore.django import modulestore
+
+from .access import has_course_access
+from .component import get_component_templates
+from student.roles import CourseCreatorRole
+from student import auth
+from util.json_request import expect_json, JsonResponse
+
+__all__ = ['library_handler']
+
+log = logging.getLogger(__name__)
+
+LIBRARIES_ENABLED = settings.FEATURES.get('ENABLE_CONTENT_LIBRARIES', False)
+
+
+@login_required
+@ensure_csrf_cookie
+def library_handler(request, library_key_string=None):
+ """
+ RESTful interface to most content library related functionality.
+ """
+ if not LIBRARIES_ENABLED:
+ raise Http404 # Should never happen because we test the feature in urls.py also
+
+ response_format = 'html'
+ if request.REQUEST.get('format', 'html') == 'json' or 'application/json' in request.META.get('HTTP_ACCEPT', 'text/html'):
+ response_format = 'json'
+
+ if library_key_string:
+ library_key = CourseKey.from_string(library_key_string)
+ if not isinstance(library_key, LibraryLocator):
+ raise Http404 # This is not a library
+ if not has_course_access(request.user, library_key):
+ raise PermissionDenied()
+
+ try:
+ library = modulestore().get_library(library_key)
+ if library is None:
+ raise ItemNotFoundError # Inconsistency: mixed modulestore returns None, whereas split raises exception
+ except ItemNotFoundError:
+ raise Http404
+
+ if not isinstance(library, LibraryDescriptor):
+ return HttpResponseBadRequest("Key specified is not a library.")
+
+ if request.method == 'GET':
+ return library_blocks_view(request, library, response_format)
+ return HttpResponseNotAllowed(['GET'])
+
+ elif request.method == 'POST':
+ # Create a new library:
+ return _create_library(request)
+ elif request.method == 'GET':
+ # List all accessible libraries:
+ lib_info = [
+ {
+ "display_name": lib.display_name,
+ "library_key": unicode(lib.location.library_key),
+ }
+ for lib in modulestore().get_libraries()
+ if has_course_access(request.user, lib.location.library_key)
+ ]
+ return JsonResponse(lib_info)
+ else:
+ return HttpResponseNotAllowed(['GET', 'POST'])
+
+
+@expect_json
+def _create_library(request):
+ """
+ Helper method for creating a new library.
+ """
+ if not auth.has_access(request.user, CourseCreatorRole()):
+ raise PermissionDenied()
+ try:
+ org = request.json['org']
+ library = request.json.get('number', None)
+ if library is None:
+ library = request.json['library']
+ display_name = request.json['display_name']
+ store = modulestore()
+ with store.default_store(ModuleStoreEnum.Type.split):
+ new_lib = store.create_library(
+ org=org,
+ library=library,
+ user_id=request.user.id,
+ fields={"display_name": display_name},
+ )
+ except KeyError as error:
+ return JsonResponse({
+ "ErrMsg": _("Unable to create library - missing expected JSON key '{err}'").format(err=error.message)}
+ )
+ except InvalidKeyError as error:
+ return JsonResponse({
+ "ErrMsg": _("Unable to create library - invalid data.\n\n{err}").format(name=display_name, err=error.message)}
+ )
+ except DuplicateCourseError as error:
+ return JsonResponse({
+ "ErrMsg": _("Unable to create library - one already exists with that key.\n\n{err}").format(err=error.message)}
+ )
+
+ lib_key_str = unicode(new_lib.location.library_key)
+ return JsonResponse({
+ 'url': reverse_library_url('library_handler', lib_key_str),
+ 'library_key': lib_key_str,
+ })
+
+
+def library_blocks_view(request, library, response_format):
+ """
+ The main view of a course's content library.
+ Shows all the XBlocks in the library, and allows adding/editing/deleting
+ them.
+ Can be called with response_format="json" to get a JSON-formatted list of
+ the XBlocks in the library along with library metadata.
+ """
+ children = library.children
+ if response_format == "json":
+ # The JSON response for this request is short and sweet:
+ prev_version = library.runtime.course_entry.structure['previous_version']
+ return JsonResponse({
+ "display_name": library.display_name,
+ "library_id": unicode(library.location.course_key), # library.course_id raises UndefinedContext - fix?
+ "version": unicode(library.runtime.course_entry.course_key.version),
+ "previous_version": unicode(prev_version) if prev_version else None,
+ "blocks": [unicode(x) for x in children],
+ })
+
+ xblock_info = create_xblock_info(library, include_ancestor_info=False, graders=[])
+
+ component_templates = get_component_templates(library)
+
+ assert isinstance(library.location.library_key, LibraryLocator)
+ assert isinstance(library.location, LibraryUsageLocator)
+
+ return render_to_response('library.html', {
+ 'context_library': library,
+ 'action': 'view',
+ 'xblock': library,
+ 'xblock_locator': library.location,
+ 'unit': None,
+ 'component_templates': json.dumps(component_templates),
+ 'xblock_info': xblock_info,
+ })
diff --git a/cms/static/js/index.js b/cms/static/js/index.js
index c9843101976b..1fa1de475aca 100644
--- a/cms/static/js/index.js
+++ b/cms/static/js/index.js
@@ -1,16 +1,16 @@
define(["domReady", "jquery", "underscore", "js/utils/cancel_on_escape", "js/views/utils/create_course_utils",
- "js/views/utils/view_utils"],
- function (domReady, $, _, CancelOnEscape, CreateCourseUtilsFactory, ViewUtils) {
+ "js/views/utils/create_library_utils", "js/views/utils/view_utils"],
+ function (domReady, $, _, CancelOnEscape, CreateCourseUtilsFactory, CreateLibraryUtilsFactory, ViewUtils) {
var CreateCourseUtils = CreateCourseUtilsFactory({
name: '.new-course-name',
org: '.new-course-org',
number: '.new-course-number',
run: '.new-course-run',
save: '.new-course-save',
- errorWrapper: '.wrap-error',
+ errorWrapper: '.create-course .wrap-error',
errorMessage: '#course_creation_error',
- tipError: 'span.tip-error',
- error: '.error',
+ tipError: '.create-course span.tip-error',
+ error: '.create-course .error',
allowUnicode: '.allow-unicode-course-id'
}, {
shown: 'is-shown',
@@ -20,6 +20,24 @@ define(["domReady", "jquery", "underscore", "js/utils/cancel_on_escape", "js/vie
error: 'error'
});
+ var CreateLibraryUtils = CreateLibraryUtilsFactory({
+ name: '.new-library-name',
+ org: '.new-library-org',
+ number: '.new-library-number',
+ save: '.new-library-save',
+ errorWrapper: '.create-library .wrap-error',
+ errorMessage: '#library_creation_error',
+ tipError: '.create-library span.tip-error',
+ error: '.create-library .error',
+ allowUnicode: '.allow-unicode-library-id'
+ }, {
+ shown: 'is-shown',
+ showing: 'is-showing',
+ hiding: 'is-hiding',
+ disabled: 'is-disabled',
+ error: 'error'
+ });
+
var saveNewCourse = function (e) {
e.preventDefault();
@@ -42,7 +60,7 @@ define(["domReady", "jquery", "underscore", "js/utils/cancel_on_escape", "js/vie
analytics.track('Created a Course', course_info);
CreateCourseUtils.createCourse(course_info, function (errorMessage) {
- $('.wrap-error').addClass('is-shown');
+ $('.create-course .wrap-error').addClass('is-shown');
$('#course_creation_error').html('
' + errorMessage + '
');
$('.new-course-save').addClass('is-disabled');
});
@@ -60,7 +78,7 @@ define(["domReady", "jquery", "underscore", "js/utils/cancel_on_escape", "js/vie
}
);
$('#course_creation_error').html('');
- $('.wrap-error').removeClass('is-shown');
+ $('.create-course .wrap-error').removeClass('is-shown');
$('.new-course-save').off('click');
};
@@ -79,8 +97,64 @@ define(["domReady", "jquery", "underscore", "js/utils/cancel_on_escape", "js/vie
CreateCourseUtils.configureHandlers();
};
+ var saveNewLibrary = function (e) {
+ e.preventDefault();
+
+ if (CreateLibraryUtils.hasInvalidRequiredFields()) {
+ return;
+ }
+
+ var $newLibraryForm = $(this).closest('#create-library-form');
+ var display_name = $newLibraryForm.find('.new-library-name').val();
+ var org = $newLibraryForm.find('.new-library-org').val();
+ var number = $newLibraryForm.find('.new-library-number').val();
+
+ lib_info = {
+ org: org,
+ number: number,
+ display_name: display_name,
+ };
+
+ analytics.track('Created a Library', lib_info);
+ CreateLibraryUtils.createLibrary(lib_info, function (errorMessage) {
+ $('.create-library .wrap-error').addClass('is-shown');
+ $('#library_creation_error').html('' + errorMessage + '
');
+ $('.new-library-save').addClass('is-disabled');
+ });
+ };
+
+ var cancelNewLibrary = function (e) {
+ e.preventDefault();
+ $('.new-library-button').removeClass('is-disabled');
+ $('.wrapper-create-library').removeClass('is-shown');
+ // Clear out existing fields and errors
+ _.each(
+ ['.new-library-name', '.new-library-org', '.new-library-number'],
+ function (field) { $(field).val(''); }
+ );
+ $('#library_creation_error').html('');
+ $('.create-library .wrap-error').removeClass('is-shown');
+ $('.new-library-save').off('click');
+ };
+
+ var addNewLibrary = function (e) {
+ e.preventDefault();
+ $('.new-library-button').addClass('is-disabled');
+ $('.new-library-save').addClass('is-disabled');
+ var $newLibrary = $('.wrapper-create-library').addClass('is-shown');
+ var $cancelButton = $newLibrary.find('.new-library-cancel');
+ var $libraryName = $('.new-library-name');
+ $libraryName.focus().select();
+ $('.new-library-save').on('click', saveNewLibrary);
+ $cancelButton.bind('click', cancelNewLibrary);
+ CancelOnEscape($cancelButton);
+
+ CreateLibraryUtils.configureHandlers();
+ };
+
var onReady = function () {
$('.new-course-button').bind('click', addNewCourse);
+ $('.new-library-button').bind('click', addNewLibrary);
$('.dismiss-button').bind('click', ViewUtils.deleteNotificationHandler(function () {
ViewUtils.reload();
}));
diff --git a/cms/static/js/spec/views/pages/course_rerun_spec.js b/cms/static/js/spec/views/pages/course_rerun_spec.js
index bcf881b98685..320f8d6557ec 100644
--- a/cms/static/js/spec/views/pages/course_rerun_spec.js
+++ b/cms/static/js/spec/views/pages/course_rerun_spec.js
@@ -49,12 +49,12 @@ define(["jquery", "js/common_helpers/ajax_helpers", "js/spec_helpers/view_helper
describe("Field validation", function () {
it("returns a message for an empty string", function () {
- var message = CreateCourseUtils.validateRequiredField('');
+ var message = ViewUtils.validateRequiredField('');
expect(message).not.toBe('');
});
it("does not return a message for a non empty string", function () {
- var message = CreateCourseUtils.validateRequiredField('edX');
+ var message = ViewUtils.validateRequiredField('edX');
expect(message).toBe('');
});
});
diff --git a/cms/static/js/views/utils/create_course_utils.js b/cms/static/js/views/utils/create_course_utils.js
index 2c0c3493ac95..88fba4a0136e 100644
--- a/cms/static/js/views/utils/create_course_utils.js
+++ b/cms/static/js/views/utils/create_course_utils.js
@@ -4,31 +4,11 @@
define(["jquery", "underscore", "gettext", "js/views/utils/view_utils"],
function ($, _, gettext, ViewUtils) {
return function (selectors, classes) {
- var validateRequiredField, validateCourseItemEncoding, validateTotalCourseItemsLength, setNewCourseFieldInErr,
- hasInvalidRequiredFields, createCourse, validateFilledFields, configureHandlers;
+ var validateTotalCourseItemsLength, setNewCourseFieldInErr, hasInvalidRequiredFields,
+ createCourse, validateFilledFields, configureHandlers;
- validateRequiredField = function (msg) {
- return msg.length === 0 ? gettext('Required field.') : '';
- };
-
- // Check that a course (org, number, run) doesn't use any special characters
- validateCourseItemEncoding = function (item) {
- var required = validateRequiredField(item);
- if (required) {
- return required;
- }
- if ($(selectors.allowUnicode).val() === 'True') {
- if (/\s/g.test(item)) {
- return gettext('Please do not use any spaces in this field.');
- }
- }
- else {
- if (item !== encodeURIComponent(item)) {
- return gettext('Please do not use any spaces or special characters in this field.');
- }
- }
- return '';
- };
+ var validateRequiredField = ViewUtils.validateRequiredField;
+ var validateURLItemEncoding = ViewUtils.validateURLItemEncoding;
// Ensure that org/course_num/run < 65 chars.
validateTotalCourseItemsLength = function () {
@@ -117,7 +97,7 @@ define(["jquery", "underscore", "gettext", "js/views/utils/view_utils"],
if (event.keyCode === 9) {
return;
}
- var error = validateCourseItemEncoding($ele.val());
+ var error = validateURLItemEncoding($ele.val(), $(selectors.allowUnicode).val() === 'True');
setNewCourseFieldInErr($ele.parent(), error);
validateTotalCourseItemsLength();
if (!validateFilledFields()) {
@@ -138,8 +118,6 @@ define(["jquery", "underscore", "gettext", "js/views/utils/view_utils"],
};
return {
- validateRequiredField: validateRequiredField,
- validateCourseItemEncoding: validateCourseItemEncoding,
validateTotalCourseItemsLength: validateTotalCourseItemsLength,
setNewCourseFieldInErr: setNewCourseFieldInErr,
hasInvalidRequiredFields: hasInvalidRequiredFields,
diff --git a/cms/static/js/views/utils/create_library_utils.js b/cms/static/js/views/utils/create_library_utils.js
new file mode 100644
index 000000000000..44acb0e97778
--- /dev/null
+++ b/cms/static/js/views/utils/create_library_utils.js
@@ -0,0 +1,129 @@
+/**
+ * Provides utilities for validating libraries during creation.
+ */
+define(["jquery", "underscore", "gettext", "js/views/utils/view_utils"],
+ function ($, _, gettext, ViewUtils) {
+ return function (selectors, classes) {
+ var validateTotalKeyLength, setNewLibraryFieldInErr, hasInvalidRequiredFields,
+ createLibrary, validateFilledFields, configureHandlers;
+
+ var validateRequiredField = ViewUtils.validateRequiredField;
+ var validateURLItemEncoding = ViewUtils.validateURLItemEncoding;
+
+ // Ensure that org/librarycode < 65 chars.
+ validateTotalKeyLength = function () {
+ var totalLength = _.reduce(
+ [selectors.org, selectors.number],
+ function (sum, ele) {
+ return sum + $(ele).val().length;
+ }, 0
+ );
+ if (totalLength > 65) {
+ $(selectors.errorWrapper).addClass(classes.shown).removeClass(classes.hiding);
+ $(selectors.errorMessage).html('' + gettext('The combined length of the organization and library code fields cannot be more than 65 characters.') + '
');
+ $(selectors.save).addClass(classes.disabled);
+ }
+ else {
+ $(selectors.errorWrapper).removeClass(classes.shown).addClass(classes.hiding);
+ }
+ };
+
+ setNewLibraryFieldInErr = function (el, msg) {
+ if (msg) {
+ el.addClass(classes.error);
+ el.children(selectors.tipError).addClass(classes.showing).removeClass(classes.hiding).text(msg);
+ $(selectors.save).addClass(classes.disabled);
+ }
+ else {
+ el.removeClass(classes.error);
+ el.children(selectors.tipError).addClass(classes.hiding).removeClass(classes.showing);
+ // One "error" div is always present, but hidden or shown
+ if ($(selectors.error).length === 1) {
+ $(selectors.save).removeClass(classes.disabled);
+ }
+ }
+ };
+
+ // One final check for empty values
+ hasInvalidRequiredFields = function () {
+ return _.reduce(
+ [selectors.name, selectors.org, selectors.number],
+ function (acc, ele) {
+ var $ele = $(ele);
+ var error = validateRequiredField($ele.val());
+ setNewLibraryFieldInErr($ele.parent(), error);
+ return error ? true : acc;
+ },
+ false
+ );
+ };
+
+ createLibrary = function (libraryInfo, errorHandler) {
+ $.postJSON(
+ '/library/',
+ libraryInfo,
+ function (data) {
+ if (data.url !== undefined) {
+ ViewUtils.redirect(data.url);
+ } else if (data.ErrMsg !== undefined) {
+ errorHandler(data.ErrMsg);
+ }
+ }
+ );
+ };
+
+ // Ensure that all fields are not empty
+ validateFilledFields = function () {
+ return _.reduce(
+ [selectors.org, selectors.number, selectors.name],
+ function (acc, ele) {
+ var $ele = $(ele);
+ return $ele.val().length !== 0 ? acc : false;
+ },
+ true
+ );
+ };
+
+ // Handle validation asynchronously
+ configureHandlers = function () {
+ _.each(
+ [selectors.org, selectors.number],
+ function (ele) {
+ var $ele = $(ele);
+ $ele.on('keyup', function (event) {
+ // Don't bother showing "required field" error when
+ // the user tabs into a new field; this is distracting
+ // and unnecessary
+ if (event.keyCode === 9) {
+ return;
+ }
+ var error = validateURLItemEncoding($ele.val(), $(selectors.allowUnicode).val() === 'True');
+ setNewLibraryFieldInErr($ele.parent(), error);
+ validateTotalKeyLength();
+ if (!validateFilledFields()) {
+ $(selectors.save).addClass(classes.disabled);
+ }
+ });
+ }
+ );
+ var $name = $(selectors.name);
+ $name.on('keyup', function () {
+ var error = validateRequiredField($name.val());
+ setNewLibraryFieldInErr($name.parent(), error);
+ validateTotalKeyLength();
+ if (!validateFilledFields()) {
+ $(selectors.save).addClass(classes.disabled);
+ }
+ });
+ };
+
+ return {
+ validateTotalKeyLength: validateTotalKeyLength,
+ setNewLibraryFieldInErr: setNewLibraryFieldInErr,
+ hasInvalidRequiredFields: hasInvalidRequiredFields,
+ createLibrary: createLibrary,
+ validateFilledFields: validateFilledFields,
+ configureHandlers: configureHandlers
+ };
+ };
+ });
diff --git a/cms/static/js/views/utils/view_utils.js b/cms/static/js/views/utils/view_utils.js
index 27d969f523f7..69f05712b357 100644
--- a/cms/static/js/views/utils/view_utils.js
+++ b/cms/static/js/views/utils/view_utils.js
@@ -5,7 +5,8 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js
function ($, _, gettext, NotificationView, PromptView) {
var toggleExpandCollapse, showLoadingIndicator, hideLoadingIndicator, confirmThenRunOperation,
runOperationShowingMessage, disableElementWhileRunning, getScrollOffset, setScrollOffset,
- setScrollTop, redirect, reload, hasChangedAttributes, deleteNotificationHandler;
+ setScrollTop, redirect, reload, hasChangedAttributes, deleteNotificationHandler,
+ validateRequiredField=1, validateURLItemEncoding=2;
/**
* Toggles the expanded state of the current element.
@@ -173,6 +174,35 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js
return false;
};
+ /**
+ * Helper method for course/library creation - verifies a required field is not blank.
+ */
+ validateRequiredField = function (msg) {
+ return msg.length === 0 ? gettext('Required field.') : '';
+ };
+
+ /**
+ * Helper method for course/library creation.
+ * Check that a course (org, number, run) doesn't use any special characters
+ */
+ validateURLItemEncoding = function (item, allowUnicode) {
+ var required = validateRequiredField(item);
+ if (required) {
+ return required;
+ }
+ if (allowUnicode) {
+ if (/\s/g.test(item)) {
+ return gettext('Please do not use any spaces in this field.');
+ }
+ }
+ else {
+ if (item !== encodeURIComponent(item)) {
+ return gettext('Please do not use any spaces or special characters in this field.');
+ }
+ }
+ return '';
+ };
+
return {
'toggleExpandCollapse': toggleExpandCollapse,
'showLoadingIndicator': showLoadingIndicator,
@@ -186,6 +216,8 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js
'setScrollOffset': setScrollOffset,
'redirect': redirect,
'reload': reload,
- 'hasChangedAttributes': hasChangedAttributes
+ 'hasChangedAttributes': hasChangedAttributes,
+ 'validateRequiredField': validateRequiredField,
+ 'validateURLItemEncoding': validateURLItemEncoding
};
});
diff --git a/cms/templates/base.html b/cms/templates/base.html
index 11c9d320aa04..a0382e462833 100644
--- a/cms/templates/base.html
+++ b/cms/templates/base.html
@@ -16,6 +16,8 @@
% if context_course:
<% ctx_loc = context_course.location %>
${context_course.display_name_with_default | h} |
+ % elif context_library:
+ ${context_library.display_name_with_default | h} |
% endif
edX Studio
diff --git a/cms/templates/index.html b/cms/templates/index.html
index 7a60b85fe33f..a558b0a7d30a 100644
--- a/cms/templates/index.html
+++ b/cms/templates/index.html
@@ -45,6 +45,10 @@ ${_("Page Actions")}
% if course_creator_status=='granted':
${_("New Course")}
+ % if libraries:
+
+ ${_("New Library")}
+ % endif
% elif course_creator_status=='disallowed_for_this_site' and settings.FEATURES.get('STUDIO_REQUEST_EMAIL',''):
${_("Email staff to create course")}
% endif
@@ -129,6 +133,56 @@ ${_("Create a New Course")}
+
+ %if libraries:
+
+ % endif
+
% endif
@@ -378,6 +432,36 @@ ${_('Your Course Creator Request Status:')}
% endif
+ %if libraries:
+
+
Content Libraries
+
${_("Warning: Content Libraries are currently a beta feature, and may be subject to backwards-incompatible changes.")}
+
${_("Here are all of the libraries you currently have access to in Studio:")}
+
+
+
+ %endif
+