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: +
+
+
+ +
+ +
+

${_("Create a New Library")}

+ +
+ ${_("Required Information to Create a New Library")} + +
    +
  1. + + + ${_("The public display name for your library.")} + +
  2. +
  3. + + + ${_("The name of the organization sponsoring the library.")} ${_("Note: This is part of your library URL, so no spaces or special characters are allowed.")} ${_("This cannot be changed.")} + +
  4. + +
  5. + + + ${_("The unique code that identifies this library.")} ${_("Note: This is part of your library URL, so no spaces or special characters are allowed and it cannot be changed.")} + +
  6. +
+ +
+
+ +
+ + + +
+
+
+ % 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 +