Merge pull request #14411 from edx/renzo/86-program-editor
Remove the program admin tool from Studio
This commit is contained in:
@@ -64,8 +64,6 @@ from openedx.core.djangoapps.content.course_structures.api.v0 import api, errors
|
||||
from openedx.core.djangoapps.credit.api import is_credit_course, get_credit_requirements
|
||||
from openedx.core.djangoapps.credit.tasks import update_credit_course_requirements
|
||||
from openedx.core.djangoapps.models.course_details import CourseDetails
|
||||
from openedx.core.djangoapps.programs.models import ProgramsApiConfig
|
||||
from openedx.core.djangoapps.programs.utils import get_programs
|
||||
from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration
|
||||
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
|
||||
from openedx.core.lib.course_tabs import CourseTabPluginManager
|
||||
@@ -469,13 +467,6 @@ def course_listing(request):
|
||||
courses, in_process_course_actions = get_courses_accessible_to_user(request)
|
||||
libraries = _accessible_libraries_list(request.user) if LIBRARIES_ENABLED else []
|
||||
|
||||
programs_config = ProgramsApiConfig.current()
|
||||
raw_programs = get_programs(request.user) if programs_config.is_studio_tab_enabled else []
|
||||
|
||||
# Sort programs alphabetically by name.
|
||||
# TODO: Support ordering in the Programs API itself.
|
||||
programs = sorted(raw_programs, key=lambda p: p['name'].lower())
|
||||
|
||||
def format_in_process_course_view(uca):
|
||||
"""
|
||||
Return a dict of the data which the view requires for each unsucceeded course
|
||||
@@ -525,9 +516,6 @@ def course_listing(request):
|
||||
'rerun_creator_status': GlobalStaff().has_user(request.user),
|
||||
'allow_unicode_course_id': settings.FEATURES.get('ALLOW_UNICODE_COURSE_ID', False),
|
||||
'allow_course_reruns': settings.FEATURES.get('ALLOW_COURSE_RERUNS', True),
|
||||
'is_programs_enabled': programs_config.is_studio_tab_enabled and request.user.is_staff,
|
||||
'programs': programs,
|
||||
'program_authoring_url': reverse('programs'),
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
"""Programs views for use with Studio."""
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.http import Http404, JsonResponse
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.generic import View
|
||||
from provider.oauth2.models import Client
|
||||
|
||||
from edxmako.shortcuts import render_to_response
|
||||
from openedx.core.djangoapps.programs.models import ProgramsApiConfig
|
||||
from openedx.core.lib.token_utils import JwtBuilder
|
||||
|
||||
|
||||
class ProgramAuthoringView(View):
|
||||
"""View rendering a template which hosts the Programs authoring app.
|
||||
|
||||
The Programs authoring app is a Backbone SPA. The app handles its own routing
|
||||
and provides a UI which can be used to create and publish new Programs.
|
||||
"""
|
||||
|
||||
@method_decorator(login_required)
|
||||
def get(self, request, *args, **kwargs):
|
||||
"""Populate the template context with values required for the authoring app to run."""
|
||||
programs_config = ProgramsApiConfig.current()
|
||||
|
||||
if programs_config.is_studio_tab_enabled and request.user.is_staff:
|
||||
return render_to_response('program_authoring.html', {
|
||||
'lms_base_url': '//{}/'.format(settings.LMS_BASE),
|
||||
'programs_api_url': programs_config.public_api_url,
|
||||
'programs_token_url': reverse('programs_id_token'),
|
||||
'studio_home_url': reverse('home'),
|
||||
'uses_pattern_library': True
|
||||
})
|
||||
else:
|
||||
raise Http404
|
||||
|
||||
|
||||
class ProgramsIdTokenView(View):
|
||||
"""Provides id tokens to JavaScript clients for use with the Programs API."""
|
||||
|
||||
@method_decorator(login_required)
|
||||
def get(self, request, *args, **kwargs):
|
||||
"""Generate and return a token, if the integration is enabled."""
|
||||
if ProgramsApiConfig.current().is_studio_tab_enabled:
|
||||
# TODO: Use the system's JWT_AUDIENCE and JWT_SECRET_KEY instead of client ID and name.
|
||||
client_name = 'programs'
|
||||
|
||||
try:
|
||||
client = Client.objects.get(name=client_name)
|
||||
except Client.DoesNotExist:
|
||||
raise ImproperlyConfigured(
|
||||
'OAuth2 Client with name [{}] does not exist.'.format(client_name)
|
||||
)
|
||||
|
||||
scopes = ['email', 'profile']
|
||||
expires_in = settings.OAUTH_ID_TOKEN_EXPIRATION
|
||||
jwt = JwtBuilder(request.user, secret=client.client_secret).build_token(
|
||||
scopes,
|
||||
expires_in,
|
||||
aud=client.client_id
|
||||
)
|
||||
|
||||
return JsonResponse({'id_token': jwt})
|
||||
else:
|
||||
raise Http404
|
||||
@@ -1,163 +0,0 @@
|
||||
"""Tests covering the Programs listing on the Studio home."""
|
||||
import json
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.urlresolvers import reverse
|
||||
import httpretty
|
||||
import mock
|
||||
from edx_oauth2_provider.tests.factories import ClientFactory
|
||||
from provider.constants import CONFIDENTIAL
|
||||
|
||||
from openedx.core.djangoapps.programs.models import ProgramsApiConfig
|
||||
from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin, ProgramsDataMixin
|
||||
from openedx.core.djangolib.markup import Text
|
||||
from student.tests.factories import UserFactory
|
||||
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
|
||||
|
||||
|
||||
class TestProgramListing(ProgramsApiConfigMixin, ProgramsDataMixin, SharedModuleStoreTestCase):
|
||||
"""Verify Program listing behavior."""
|
||||
def setUp(self):
|
||||
super(TestProgramListing, self).setUp()
|
||||
|
||||
ClientFactory(name=ProgramsApiConfig.OAUTH2_CLIENT_NAME, client_type=CONFIDENTIAL)
|
||||
|
||||
self.staff = UserFactory(is_staff=True)
|
||||
self.client.login(username=self.staff.username, password='test')
|
||||
|
||||
self.studio_home = reverse('home')
|
||||
|
||||
@httpretty.activate
|
||||
def test_programs_config_disabled(self):
|
||||
"""Verify that the programs tab and creation button aren't rendered when config is disabled."""
|
||||
self.create_programs_config(enable_studio_tab=False)
|
||||
self.mock_programs_api()
|
||||
|
||||
response = self.client.get(self.studio_home)
|
||||
|
||||
self.assertNotIn("You haven't created any programs yet.", response.content)
|
||||
|
||||
for program_name in self.PROGRAM_NAMES:
|
||||
self.assertNotIn(program_name, response.content)
|
||||
|
||||
@httpretty.activate
|
||||
def test_programs_requires_staff(self):
|
||||
"""
|
||||
Verify that the programs tab and creation button aren't rendered unless the user has
|
||||
global staff permissions.
|
||||
"""
|
||||
student = UserFactory(is_staff=False)
|
||||
self.client.login(username=student.username, password='test')
|
||||
|
||||
self.create_programs_config()
|
||||
self.mock_programs_api()
|
||||
|
||||
response = self.client.get(self.studio_home)
|
||||
self.assertNotIn("You haven't created any programs yet.", response.content)
|
||||
|
||||
@httpretty.activate
|
||||
def test_programs_displayed(self):
|
||||
"""Verify that the programs tab and creation button can be rendered when config is enabled."""
|
||||
|
||||
# When no data is provided, expect creation prompt.
|
||||
self.create_programs_config()
|
||||
self.mock_programs_api(data={'results': []})
|
||||
|
||||
response = self.client.get(self.studio_home)
|
||||
self.assertIn(Text("You haven't created any programs yet."), response.content.decode('utf-8'))
|
||||
|
||||
# When data is provided, expect a program listing.
|
||||
self.mock_programs_api()
|
||||
|
||||
response = self.client.get(self.studio_home)
|
||||
for program_name in self.PROGRAM_NAMES:
|
||||
self.assertIn(program_name, response.content)
|
||||
|
||||
|
||||
class TestProgramAuthoringView(ProgramsApiConfigMixin, SharedModuleStoreTestCase):
|
||||
"""Verify the behavior of the program authoring app's host view."""
|
||||
def setUp(self):
|
||||
super(TestProgramAuthoringView, self).setUp()
|
||||
|
||||
self.staff = UserFactory(is_staff=True)
|
||||
self.programs_path = reverse('programs')
|
||||
|
||||
def _assert_status(self, status_code):
|
||||
"""Verify the status code returned by the Program authoring view."""
|
||||
response = self.client.get(self.programs_path)
|
||||
self.assertEquals(response.status_code, status_code)
|
||||
|
||||
return response
|
||||
|
||||
def test_authoring_login_required(self):
|
||||
"""Verify that accessing the view requires the user to be authenticated."""
|
||||
response = self.client.get(self.programs_path)
|
||||
self.assertRedirects(
|
||||
response,
|
||||
'{login_url}?next={programs}'.format(
|
||||
login_url=settings.LOGIN_URL,
|
||||
programs=self.programs_path
|
||||
)
|
||||
)
|
||||
|
||||
def test_authoring_header(self):
|
||||
"""Verify that the header contains the expected text."""
|
||||
self.client.login(username=self.staff.username, password='test')
|
||||
self.create_programs_config()
|
||||
|
||||
response = self._assert_status(200)
|
||||
self.assertIn("Program Administration", response.content)
|
||||
|
||||
def test_authoring_access(self):
|
||||
"""
|
||||
Verify that a 404 is returned if Programs authoring is disabled, or the user does not have
|
||||
global staff permissions.
|
||||
"""
|
||||
self.client.login(username=self.staff.username, password='test')
|
||||
self._assert_status(404)
|
||||
|
||||
# Enable Programs authoring interface
|
||||
self.create_programs_config()
|
||||
|
||||
student = UserFactory(is_staff=False)
|
||||
self.client.login(username=student.username, password='test')
|
||||
self._assert_status(404)
|
||||
|
||||
|
||||
class TestProgramsIdTokenView(ProgramsApiConfigMixin, SharedModuleStoreTestCase):
|
||||
"""Tests for the programs id_token endpoint."""
|
||||
|
||||
def setUp(self):
|
||||
super(TestProgramsIdTokenView, self).setUp()
|
||||
self.user = UserFactory()
|
||||
self.client.login(username=self.user.username, password='test')
|
||||
self.path = reverse('programs_id_token')
|
||||
|
||||
def test_config_disabled(self):
|
||||
"""Ensure the endpoint returns 404 when Programs authoring is disabled."""
|
||||
self.create_programs_config(enable_studio_tab=False)
|
||||
response = self.client.get(self.path)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_not_logged_in(self):
|
||||
"""Ensure the endpoint denies access to unauthenticated users."""
|
||||
self.create_programs_config()
|
||||
self.client.logout()
|
||||
response = self.client.get(self.path)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertIn(settings.LOGIN_URL, response['Location'])
|
||||
|
||||
@mock.patch('cms.djangoapps.contentstore.views.program.JwtBuilder.build_token')
|
||||
def test_config_enabled(self, mock_build_token):
|
||||
"""
|
||||
Ensure the endpoint responds with a valid JSON payload when authoring
|
||||
is enabled.
|
||||
"""
|
||||
mock_build_token.return_value = 'test-id-token'
|
||||
ClientFactory(name=ProgramsApiConfig.OAUTH2_CLIENT_NAME, client_type=CONFIDENTIAL)
|
||||
|
||||
self.create_programs_config()
|
||||
response = self.client.get(self.path)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
payload = json.loads(response.content)
|
||||
self.assertEqual(payload, {'id_token': 'test-id-token'})
|
||||
@@ -908,9 +908,6 @@ INSTALLED_APPS = (
|
||||
# Bookmarks
|
||||
'openedx.core.djangoapps.bookmarks',
|
||||
|
||||
# programs support
|
||||
'openedx.core.djangoapps.programs',
|
||||
|
||||
# Catalog integration
|
||||
'openedx.core.djangoapps.catalog',
|
||||
|
||||
|
||||
@@ -53,8 +53,7 @@
|
||||
'js/factories/settings_graders',
|
||||
'js/factories/textbooks',
|
||||
'js/factories/videos_index',
|
||||
'js/factories/xblock_validation',
|
||||
'js/programs/program_admin_app'
|
||||
'js/factories/xblock_validation'
|
||||
]),
|
||||
/**
|
||||
* By default all the configuration for optimization happens from the command
|
||||
|
||||
@@ -59,7 +59,6 @@
|
||||
'underscore.string': 'common/js/vendor/underscore.string',
|
||||
'backbone': 'common/js/vendor/backbone',
|
||||
'backbone-relational': 'js/vendor/backbone-relational.min',
|
||||
'backbone.validation': 'common/js/vendor/backbone-validation-min',
|
||||
'backbone.associations': 'js/vendor/backbone-associations-min',
|
||||
'backbone.paginator': 'common/js/vendor/backbone.paginator',
|
||||
'tinymce': 'js/vendor/tinymce/js/tinymce/tinymce.full.min',
|
||||
|
||||
@@ -56,7 +56,6 @@
|
||||
'backbone': 'common/js/vendor/backbone',
|
||||
'backbone.associations': 'xmodule_js/common_static/js/vendor/backbone-associations-min',
|
||||
'backbone.paginator': 'common/js/vendor/backbone.paginator',
|
||||
'backbone.validation': 'common/js/vendor/backbone-validation-min',
|
||||
'backbone-relational': 'xmodule_js/common_static/js/vendor/backbone-relational.min',
|
||||
'tinymce': 'xmodule_js/common_static/js/vendor/tinymce/js/tinymce/tinymce.full.min',
|
||||
'jquery.tinymce': 'xmodule_js/common_static/js/vendor/tinymce/js/tinymce/jquery.tinymce',
|
||||
@@ -290,10 +289,7 @@
|
||||
'js/certificates/spec/views/certificate_details_spec',
|
||||
'js/certificates/spec/views/certificate_editor_spec',
|
||||
'js/certificates/spec/views/certificates_list_spec',
|
||||
'js/certificates/spec/views/certificate_preview_spec',
|
||||
'js/spec/models/auto_auth_model_spec',
|
||||
'js/spec/views/programs/program_creator_spec',
|
||||
'js/spec/views/programs/program_details_spec'
|
||||
'js/certificates/spec/views/certificate_preview_spec'
|
||||
];
|
||||
|
||||
i = 0;
|
||||
|
||||
@@ -37,7 +37,6 @@
|
||||
'backbone': 'common/js/vendor/backbone',
|
||||
'backbone.associations': 'xmodule_js/common_static/js/vendor/backbone-associations-min',
|
||||
'backbone.paginator': 'common/js/vendor/backbone.paginator',
|
||||
'backbone.validation': 'common/js/vendor/backbone-validation',
|
||||
'tinymce': 'xmodule_js/common_static/js/vendor/tinymce/js/tinymce/tinymce.full.min',
|
||||
'jquery.tinymce': 'xmodule_js/common_static/js/vendor/tinymce/js/tinymce/jquery.tinymce',
|
||||
'xmodule': 'xmodule_js/src/xmodule',
|
||||
|
||||
@@ -162,7 +162,6 @@ define(['domReady', 'jquery', 'underscore', 'js/utils/cancel_on_escape', 'js/vie
|
||||
e.preventDefault();
|
||||
$('.courses-tab').toggleClass('active', tab === 'courses');
|
||||
$('.libraries-tab').toggleClass('active', tab === 'libraries');
|
||||
$('.programs-tab').toggleClass('active', tab === 'programs');
|
||||
|
||||
// Also toggle this course-related notice shown below the course tab, if it is present:
|
||||
$('.wrapper-creationrights').toggleClass('is-hidden', tab !== 'courses');
|
||||
@@ -181,7 +180,6 @@ define(['domReady', 'jquery', 'underscore', 'js/utils/cancel_on_escape', 'js/vie
|
||||
|
||||
$('#course-index-tabs .courses-tab').bind('click', showTab('courses'));
|
||||
$('#course-index-tabs .libraries-tab').bind('click', showTab('libraries'));
|
||||
$('#course-index-tabs .programs-tab').bind('click', showTab('programs'));
|
||||
};
|
||||
|
||||
domReady(onReady);
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
define([
|
||||
'backbone',
|
||||
'jquery',
|
||||
'js/programs/utils/api_config',
|
||||
'jquery.cookie'
|
||||
],
|
||||
function(Backbone, $, apiConfig) {
|
||||
'use strict';
|
||||
|
||||
return Backbone.Collection.extend({
|
||||
allRuns: [],
|
||||
|
||||
initialize: function(models, options) {
|
||||
// Ignore pagination and give me everything
|
||||
var orgStr = options.organization.key,
|
||||
queries = '?org=' + orgStr + '&page_size=1000';
|
||||
|
||||
this.url = apiConfig.get('lmsBaseUrl') + 'api/courses/v1/courses/' + queries;
|
||||
},
|
||||
|
||||
/*
|
||||
* Abridged version of Backbone.Collection.Create that does not
|
||||
* save the updated Collection back to the server
|
||||
* (code based on original function - http://backbonejs.org/docs/backbone.html#section-134)
|
||||
*/
|
||||
create: function(model, options) {
|
||||
options = options ? _.clone(options) : {};
|
||||
model = this._prepareModel(model, options);
|
||||
|
||||
if (!!model) {
|
||||
this.add(model, options);
|
||||
return model;
|
||||
}
|
||||
},
|
||||
|
||||
parse: function(data) {
|
||||
this.allRuns = data.results;
|
||||
|
||||
// Because pagination is ignored just set results
|
||||
return data.results;
|
||||
},
|
||||
|
||||
// Adds a run back into the model for selection
|
||||
addRun: function(id) {
|
||||
var courseRun = _.findWhere(this.allRuns, {id: id});
|
||||
|
||||
this.create(courseRun);
|
||||
},
|
||||
|
||||
// Removes a run from the model for selection
|
||||
removeRun: function(id) {
|
||||
var courseRun = this.where({id: id});
|
||||
|
||||
this.remove(courseRun);
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -1,13 +0,0 @@
|
||||
define([
|
||||
'backbone',
|
||||
'jquery',
|
||||
'js/programs/models/program_model'
|
||||
],
|
||||
function(Backbone, $, ProgramModel) {
|
||||
'use strict';
|
||||
|
||||
return Backbone.Collection.extend({
|
||||
model: ProgramModel
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -1,17 +0,0 @@
|
||||
define([
|
||||
'backbone'
|
||||
],
|
||||
function(Backbone) {
|
||||
'use strict';
|
||||
|
||||
return Backbone.Model.extend({
|
||||
defaults: {
|
||||
username: '',
|
||||
lmsBaseUrl: '',
|
||||
programsApiUrl: '',
|
||||
authUrl: '/programs/id_token/',
|
||||
idToken: ''
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -1,10 +0,0 @@
|
||||
define([
|
||||
'backbone',
|
||||
'js/programs/utils/auth_utils'
|
||||
],
|
||||
function(Backbone, auth) {
|
||||
'use strict';
|
||||
|
||||
return Backbone.Model.extend(auth.autoSync);
|
||||
}
|
||||
);
|
||||
@@ -1,38 +0,0 @@
|
||||
define([
|
||||
'backbone',
|
||||
'jquery',
|
||||
'js/programs/utils/api_config',
|
||||
'js/programs/models/auto_auth_model',
|
||||
'jquery.cookie',
|
||||
'gettext'
|
||||
],
|
||||
function(Backbone, $, apiConfig, AutoAuthModel) {
|
||||
'use strict';
|
||||
|
||||
return AutoAuthModel.extend({
|
||||
|
||||
validation: {
|
||||
key: {
|
||||
required: true,
|
||||
maxLength: 64
|
||||
},
|
||||
display_name: {
|
||||
required: true,
|
||||
maxLength: 128
|
||||
}
|
||||
},
|
||||
|
||||
labels: {
|
||||
key: gettext('Course Code'),
|
||||
display_name: gettext('Course Title')
|
||||
},
|
||||
|
||||
defaults: {
|
||||
display_name: false,
|
||||
key: false,
|
||||
organization: [],
|
||||
run_modes: []
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -1,17 +0,0 @@
|
||||
define([
|
||||
'backbone'
|
||||
],
|
||||
function(Backbone) {
|
||||
'use strict';
|
||||
|
||||
return Backbone.Model.extend({
|
||||
defaults: {
|
||||
course_key: '',
|
||||
mode_slug: 'verified',
|
||||
sku: '',
|
||||
start_date: '',
|
||||
run_key: ''
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -1,16 +0,0 @@
|
||||
define([
|
||||
'js/programs/utils/api_config',
|
||||
'js/programs/models/auto_auth_model'
|
||||
],
|
||||
function(apiConfig, AutoAuthModel) {
|
||||
'use strict';
|
||||
|
||||
return AutoAuthModel.extend({
|
||||
|
||||
url: function() {
|
||||
return apiConfig.get('programsApiUrl') + 'organizations/?page_size=1000';
|
||||
}
|
||||
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -1,107 +0,0 @@
|
||||
define([
|
||||
'backbone',
|
||||
'jquery',
|
||||
'js/programs/utils/api_config',
|
||||
'js/programs/models/auto_auth_model',
|
||||
'gettext',
|
||||
'jquery.cookie'
|
||||
],
|
||||
function(Backbone, $, apiConfig, AutoAuthModel, gettext) {
|
||||
'use strict';
|
||||
|
||||
return AutoAuthModel.extend({
|
||||
|
||||
// Backbone.Validation rules.
|
||||
// See: http://thedersen.com/projects/backbone-validation/#configure-validation-rules-on-the-model.
|
||||
validation: {
|
||||
name: {
|
||||
required: true,
|
||||
maxLength: 255
|
||||
},
|
||||
subtitle: {
|
||||
// The underlying Django model does not require a subtitle.
|
||||
maxLength: 255
|
||||
},
|
||||
category: {
|
||||
required: true,
|
||||
// TODO: Populate with the results of an API call for valid categories.
|
||||
oneOf: ['XSeries', 'MicroMasters']
|
||||
},
|
||||
organizations: 'validateOrganizations',
|
||||
marketing_slug: {
|
||||
maxLength: 255
|
||||
}
|
||||
},
|
||||
|
||||
initialize: function() {
|
||||
this.url = apiConfig.get('programsApiUrl') + 'programs/' + this.id + '/';
|
||||
},
|
||||
|
||||
validateOrganizations: function(orgArray) {
|
||||
/**
|
||||
* The array passed to this method contains a single object representing
|
||||
* the selected organization; the object contains the organization's key.
|
||||
* In the future, multiple organizations might be associated with a program.
|
||||
*/
|
||||
var i,
|
||||
len = orgArray ? orgArray.length : 0;
|
||||
|
||||
for (i = 0; i < len; i++) {
|
||||
if (orgArray[i].key === 'false') {
|
||||
return gettext('Please select a valid organization.');
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
getConfig: function(options) {
|
||||
var patch = options && options.patch,
|
||||
params = patch ? this.get('id') + '/' : '',
|
||||
config = _.extend({validate: true, parse: true}, {
|
||||
type: patch ? 'PATCH' : 'POST',
|
||||
url: apiConfig.get('programsApiUrl') + 'programs/' + params,
|
||||
contentType: patch ? 'application/merge-patch+json' : 'application/json',
|
||||
context: this,
|
||||
// NB: setting context fails in tests
|
||||
success: _.bind(this.saveSuccess, this),
|
||||
error: _.bind(this.saveError, this)
|
||||
});
|
||||
|
||||
if (patch) {
|
||||
config.data = JSON.stringify(options.update) || this.attributes;
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
|
||||
patch: function(data) {
|
||||
this.save({
|
||||
patch: true,
|
||||
update: data
|
||||
});
|
||||
},
|
||||
|
||||
save: function(options) {
|
||||
var method,
|
||||
patch = options && options.patch ? true : false,
|
||||
config = this.getConfig(options);
|
||||
|
||||
/**
|
||||
* Simplified version of code from the default Backbone save function
|
||||
* http://backbonejs.org/docs/backbone.html#section-87
|
||||
*/
|
||||
method = this.isNew() ? 'create' : (patch ? 'patch' : 'update');
|
||||
|
||||
this.sync(method, this, config);
|
||||
},
|
||||
|
||||
saveError: function(jqXHR) {
|
||||
this.trigger('error', jqXHR);
|
||||
},
|
||||
|
||||
saveSuccess: function(data) {
|
||||
this.set({id: data.id});
|
||||
this.trigger('sync', this);
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -1,10 +0,0 @@
|
||||
(function() {
|
||||
'use strict';
|
||||
require([
|
||||
'js/programs/views/program_admin_app_view'
|
||||
],
|
||||
function(ProgramAdminAppView) {
|
||||
return new ProgramAdminAppView();
|
||||
}
|
||||
);
|
||||
})();
|
||||
@@ -1,65 +0,0 @@
|
||||
define([
|
||||
'backbone',
|
||||
'js/programs/views/program_creator_view',
|
||||
'js/programs/views/program_details_view',
|
||||
'js/programs/models/program_model'
|
||||
],
|
||||
function(Backbone, ProgramCreatorView, ProgramDetailsView, ProgramModel) {
|
||||
'use strict';
|
||||
|
||||
return Backbone.Router.extend({
|
||||
root: '/program/',
|
||||
|
||||
routes: {
|
||||
'new': 'programCreator',
|
||||
':id': 'programDetails'
|
||||
},
|
||||
|
||||
initialize: function(options) {
|
||||
this.homeUrl = options.homeUrl;
|
||||
},
|
||||
|
||||
goHome: function() {
|
||||
window.location.href = this.homeUrl;
|
||||
},
|
||||
|
||||
loadProgramDetails: function() {
|
||||
this.programDetailsView = new ProgramDetailsView({
|
||||
model: this.programModel
|
||||
});
|
||||
},
|
||||
|
||||
programCreator: function() {
|
||||
if (this.programCreatorView) {
|
||||
this.programCreatorView.destroy();
|
||||
}
|
||||
|
||||
this.programCreatorView = new ProgramCreatorView({
|
||||
router: this
|
||||
});
|
||||
},
|
||||
|
||||
programDetails: function(id) {
|
||||
this.programModel = new ProgramModel({
|
||||
id: id
|
||||
});
|
||||
|
||||
this.programModel.on('sync', this.loadProgramDetails, this);
|
||||
this.programModel.fetch();
|
||||
},
|
||||
|
||||
/**
|
||||
* Starts the router.
|
||||
*/
|
||||
start: function() {
|
||||
if (!Backbone.history.started) {
|
||||
Backbone.history.start({
|
||||
pushState: true,
|
||||
root: this.root
|
||||
});
|
||||
}
|
||||
return this;
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -1,22 +0,0 @@
|
||||
/**
|
||||
* the Programs application loads gettext identity library via django, thus
|
||||
* components reference gettext globally so a shim is added here to reflect
|
||||
* the text so tests can be run if modules reference gettext
|
||||
*/
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
if (!window.gettext) {
|
||||
window.gettext = function(text) {
|
||||
return text;
|
||||
};
|
||||
}
|
||||
|
||||
if (!window.interpolate) {
|
||||
window.interpolate = function(text) {
|
||||
return text;
|
||||
};
|
||||
}
|
||||
|
||||
return window;
|
||||
})();
|
||||
@@ -1,20 +0,0 @@
|
||||
define([
|
||||
'js/programs/models/api_config_model'
|
||||
],
|
||||
function(ApiConfigModel) {
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* This js module implements the Singleton pattern for an instance
|
||||
* of the ApiConfigModel Backbone model. It returns the same shared
|
||||
* instance of that model anywhere it is required.
|
||||
*/
|
||||
var instance;
|
||||
|
||||
if (instance === undefined) {
|
||||
instance = new ApiConfigModel();
|
||||
}
|
||||
|
||||
return instance;
|
||||
}
|
||||
);
|
||||
@@ -1,87 +0,0 @@
|
||||
define([
|
||||
'jquery',
|
||||
'underscore',
|
||||
'js/programs/utils/api_config'
|
||||
],
|
||||
function($, _, apiConfig) {
|
||||
'use strict';
|
||||
|
||||
var auth = {
|
||||
autoSync: {
|
||||
/**
|
||||
* Override Backbone.sync to seamlessly attempt (re-)authentication when necessary.
|
||||
*
|
||||
* If a 401 error response is encountered while making a request to the Programs,
|
||||
* API, this wrapper will attempt to request an id token from a custom endpoint
|
||||
* via AJAX. Then the original request will be retried once more.
|
||||
*
|
||||
* Any other response than 401 on the original API request, or any error occurring
|
||||
* on the retried API request (including 401), will be handled by the base sync
|
||||
* implementation.
|
||||
*
|
||||
*/
|
||||
sync: function(method, model, options) {
|
||||
var oldError = options.error;
|
||||
|
||||
this._setHeaders(options);
|
||||
|
||||
options.notifyOnError = false; // suppress Studio error pop-up that will happen if we get a 401
|
||||
|
||||
options.error = function(xhr, textStatus, errorThrown) {
|
||||
if (xhr && xhr.status === 401) {
|
||||
// attempt auth and retry
|
||||
this._updateToken(function() {
|
||||
// restore the original error handler
|
||||
options.error = oldError;
|
||||
options.notifyOnError = true; // if it fails again, let Studio notify.
|
||||
delete options.xhr; // remove the failed (401) xhr from the last try.
|
||||
|
||||
// update authorization header
|
||||
this._setHeaders(options);
|
||||
|
||||
Backbone.sync.call(this, method, model, options);
|
||||
}.bind(this));
|
||||
} else if (oldError) {
|
||||
// fall back to the original error handler
|
||||
oldError.call(this, xhr, textStatus, errorThrown);
|
||||
}
|
||||
}.bind(this);
|
||||
return Backbone.sync.call(this, method, model, options);
|
||||
},
|
||||
|
||||
/**
|
||||
* Fix up headers on an imminent AJAX sync, ensuring that the JWT token is enclosed
|
||||
* and that credentials are included when the request is being made cross-domain.
|
||||
*/
|
||||
_setHeaders: function(ajaxOptions) {
|
||||
ajaxOptions.headers = _.extend(ajaxOptions.headers || {}, {
|
||||
Authorization: 'JWT ' + apiConfig.get('idToken')
|
||||
});
|
||||
ajaxOptions.xhrFields = _.extend(ajaxOptions.xhrFields || {}, {
|
||||
withCredentials: true
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch a new id token from the configured endpoint, update the api config,
|
||||
* and invoke the specified callback.
|
||||
*/
|
||||
_updateToken: function(success) {
|
||||
$.ajax({
|
||||
url: apiConfig.get('authUrl'),
|
||||
xhrFields: {
|
||||
// See: https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/withCredentials
|
||||
withCredentials: true
|
||||
},
|
||||
crossDomain: true
|
||||
}).done(function(data) {
|
||||
// save the newly-retrieved id token
|
||||
apiConfig.set('idToken', data.id_token);
|
||||
}).done(success);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return auth;
|
||||
}
|
||||
);
|
||||
@@ -1,16 +0,0 @@
|
||||
/**
|
||||
* Reusable constants
|
||||
*/
|
||||
define([], function() {
|
||||
'use strict';
|
||||
|
||||
return {
|
||||
keyCodes: {
|
||||
tab: 9,
|
||||
enter: 13,
|
||||
esc: 27,
|
||||
up: 38,
|
||||
down: 40
|
||||
}
|
||||
};
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
define([
|
||||
'backbone',
|
||||
'backbone.validation',
|
||||
'underscore',
|
||||
'gettext'
|
||||
],
|
||||
function(Backbone, BackboneValidation, _, gettext) {
|
||||
'use strict';
|
||||
|
||||
var errorClass = 'has-error',
|
||||
messageEl = '.field-message',
|
||||
messageContent = '.field-message-content';
|
||||
|
||||
// These are the same messages provided by Backbone.Validation,
|
||||
// marked for translation.
|
||||
// See: http://thedersen.com/projects/backbone-validation/#overriding-the-default-error-messages.
|
||||
_.extend(Backbone.Validation.messages, {
|
||||
required: gettext('{0} is required'),
|
||||
acceptance: gettext('{0} must be accepted'),
|
||||
min: gettext('{0} must be greater than or equal to {1}'),
|
||||
max: gettext('{0} must be less than or equal to {1}'),
|
||||
range: gettext('{0} must be between {1} and {2}'),
|
||||
length: gettext('{0} must be {1} characters'),
|
||||
minLength: gettext('{0} must be at least {1} characters'),
|
||||
maxLength: gettext('{0} must be at most {1} characters'),
|
||||
rangeLength: gettext('{0} must be between {1} and {2} characters'),
|
||||
oneOf: gettext('{0} must be one of: {1}'),
|
||||
equalTo: gettext('{0} must be the same as {1}'),
|
||||
digits: gettext('{0} must only contain digits'),
|
||||
number: gettext('{0} must be a number'),
|
||||
email: gettext('{0} must be a valid email'),
|
||||
url: gettext('{0} must be a valid url'),
|
||||
inlinePattern: gettext('{0} is invalid')
|
||||
});
|
||||
|
||||
_.extend(Backbone.Validation.callbacks, {
|
||||
// Gets called when a previously invalid field in the
|
||||
// view becomes valid. Removes any error message.
|
||||
valid: function(view, attr, selector) {
|
||||
var $input = view.$('[' + selector + '~="' + attr + '"]'),
|
||||
$message = $input.siblings(messageEl);
|
||||
|
||||
$input.removeClass(errorClass)
|
||||
.removeAttr('data-error');
|
||||
|
||||
$message.removeClass(errorClass)
|
||||
.find(messageContent)
|
||||
.text('');
|
||||
},
|
||||
|
||||
// Gets called when a field in the view becomes invalid.
|
||||
// Adds a error message.
|
||||
invalid: function(view, attr, error, selector) {
|
||||
var $input = view.$('[' + selector + '~="' + attr + '"]'),
|
||||
$message = $input.siblings(messageEl);
|
||||
|
||||
$input.addClass(errorClass)
|
||||
.attr('data-error', error);
|
||||
|
||||
$message.addClass(errorClass)
|
||||
.find(messageContent)
|
||||
.text($input.data('error'));
|
||||
}
|
||||
});
|
||||
|
||||
Backbone.Validation.configure({
|
||||
labelFormatter: 'label'
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -1,59 +0,0 @@
|
||||
define([
|
||||
'backbone',
|
||||
'jquery',
|
||||
'underscore',
|
||||
'js/programs/utils/constants',
|
||||
'text!templates/programs/confirm_modal.underscore',
|
||||
'edx-ui-toolkit/js/utils/html-utils',
|
||||
'gettext'
|
||||
],
|
||||
function(Backbone, $, _, constants, ModalTpl, HtmlUtils) {
|
||||
'use strict';
|
||||
|
||||
return Backbone.View.extend({
|
||||
events: {
|
||||
'click .js-cancel': 'destroy',
|
||||
'click .js-confirm': 'confirm',
|
||||
'keydown': 'handleKeydown'
|
||||
},
|
||||
|
||||
tpl: HtmlUtils.template(ModalTpl),
|
||||
|
||||
initialize: function(options) {
|
||||
this.$parentEl = $(options.parentEl);
|
||||
this.callback = options.callback;
|
||||
this.content = options.content;
|
||||
this.render();
|
||||
},
|
||||
|
||||
render: function() {
|
||||
HtmlUtils.setHtml(this.$el, this.tpl(this.content));
|
||||
HtmlUtils.setHtml(this.$parentEl, HtmlUtils.HTML(this.$el));
|
||||
this.postRender();
|
||||
},
|
||||
|
||||
postRender: function() {
|
||||
this.$el.find('.js-focus-first').focus();
|
||||
},
|
||||
|
||||
confirm: function() {
|
||||
this.callback();
|
||||
this.destroy();
|
||||
},
|
||||
|
||||
destroy: function() {
|
||||
this.undelegateEvents();
|
||||
this.remove();
|
||||
this.$parentEl.html('');
|
||||
},
|
||||
|
||||
handleKeydown: function(event) {
|
||||
var keyCode = event.keyCode;
|
||||
|
||||
if (keyCode === constants.keyCodes.esc) {
|
||||
this.destroy();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -1,204 +0,0 @@
|
||||
define([
|
||||
'backbone',
|
||||
'backbone.validation',
|
||||
'jquery',
|
||||
'underscore',
|
||||
'js/programs/models/course_model',
|
||||
'js/programs/models/course_run_model',
|
||||
'js/programs/models/program_model',
|
||||
'js/programs/views/course_run_view',
|
||||
'text!templates/programs/course_details.underscore',
|
||||
'edx-ui-toolkit/js/utils/html-utils',
|
||||
'gettext',
|
||||
'js/programs/utils/validation_config'
|
||||
],
|
||||
function(Backbone, BackboneValidation, $, _, CourseModel, CourseRunModel,
|
||||
ProgramModel, CourseRunView, ListTpl, HtmlUtils) {
|
||||
'use strict';
|
||||
|
||||
return Backbone.View.extend({
|
||||
parentEl: '.js-course-list',
|
||||
|
||||
className: 'course-details',
|
||||
|
||||
events: {
|
||||
'click .js-remove-course': 'destroy',
|
||||
'click .js-select-course': 'setCourse',
|
||||
'click .js-add-course-run': 'addCourseRun'
|
||||
},
|
||||
|
||||
tpl: HtmlUtils.template(ListTpl),
|
||||
|
||||
initialize: function(options) {
|
||||
this.model = new CourseModel();
|
||||
Backbone.Validation.bind(this);
|
||||
this.$parentEl = $(this.parentEl);
|
||||
|
||||
// For managing subViews
|
||||
this.courseRunViews = [];
|
||||
this.courseRuns = options.courseRuns;
|
||||
this.programModel = options.programModel;
|
||||
|
||||
if (options.courseData) {
|
||||
this.model.set(options.courseData);
|
||||
} else {
|
||||
this.model.set({run_modes: []});
|
||||
}
|
||||
|
||||
// Need a unique value for field ids so using model cid
|
||||
this.model.set({cid: this.model.cid});
|
||||
this.model.on('change:run_modes', this.updateRuns, this);
|
||||
this.render();
|
||||
},
|
||||
|
||||
render: function() {
|
||||
HtmlUtils.setHtml(this.$el, this.tpl(this.formatData()));
|
||||
this.$parentEl.append(this.$el);
|
||||
this.postRender();
|
||||
},
|
||||
|
||||
postRender: function() {
|
||||
var runs = this.model.get('run_modes');
|
||||
if (runs && runs.length > 0) {
|
||||
this.addCourseRuns();
|
||||
}
|
||||
},
|
||||
|
||||
addCourseRun: function(event) {
|
||||
var $runsContainer = this.$el.find('.js-course-runs'),
|
||||
runModel = new CourseRunModel(),
|
||||
runView;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
runModel.set({course_key: undefined});
|
||||
|
||||
runView = new CourseRunView({
|
||||
model: runModel,
|
||||
courseModel: this.model,
|
||||
courseRuns: this.courseRuns,
|
||||
programStatus: this.programModel.get('status'),
|
||||
$parentEl: $runsContainer
|
||||
});
|
||||
|
||||
this.courseRunViews.push(runView);
|
||||
},
|
||||
|
||||
addCourseRuns: function() {
|
||||
// Create run views
|
||||
var runs = this.model.get('run_modes'),
|
||||
$runsContainer = this.$el.find('.js-course-runs');
|
||||
|
||||
_.each(runs, function(run) {
|
||||
var runModel = new CourseRunModel(),
|
||||
runView;
|
||||
|
||||
runModel.set(run);
|
||||
|
||||
runView = new CourseRunView({
|
||||
model: runModel,
|
||||
courseModel: this.model,
|
||||
courseRuns: this.courseRuns,
|
||||
programStatus: this.programModel.get('status'),
|
||||
$parentEl: $runsContainer
|
||||
});
|
||||
|
||||
this.courseRunViews.push(runView);
|
||||
|
||||
return runView;
|
||||
}.bind(this));
|
||||
},
|
||||
|
||||
addCourseToProgram: function() {
|
||||
var courseCodes = this.programModel.get('course_codes'),
|
||||
courseData = this.model.toJSON();
|
||||
|
||||
if (this.programModel.isValid(true)) {
|
||||
// We don't want to save the cid so omit it
|
||||
courseCodes.push(_.omit(courseData, 'cid'));
|
||||
this.programModel.patch({course_codes: courseCodes});
|
||||
}
|
||||
},
|
||||
// Delete this view
|
||||
destroy: function() {
|
||||
Backbone.Validation.unbind(this);
|
||||
this.destroyChildren();
|
||||
this.undelegateEvents();
|
||||
this.removeCourseFromProgram();
|
||||
this.remove();
|
||||
},
|
||||
|
||||
destroyChildren: function() {
|
||||
var runs = this.courseRunViews;
|
||||
|
||||
_.each(runs, function(run) {
|
||||
run.removeRun();
|
||||
});
|
||||
},
|
||||
|
||||
// Format data to be passed to the template
|
||||
formatData: function() {
|
||||
var data = $.extend({},
|
||||
{courseRuns: this.courseRuns.models},
|
||||
_.omit(this.programModel.toJSON(), 'run_modes'),
|
||||
this.model.toJSON()
|
||||
);
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
removeCourseFromProgram: function() {
|
||||
var courseCodes = this.programModel.get('course_codes'),
|
||||
key = this.model.get('key'),
|
||||
name = this.model.get('display_name'),
|
||||
update = [];
|
||||
|
||||
update = _.reject(courseCodes, function(course) {
|
||||
return course.key === key && course.display_name === name;
|
||||
});
|
||||
|
||||
this.programModel.patch({course_codes: update});
|
||||
},
|
||||
|
||||
setCourse: function(event) {
|
||||
var $form = this.$('.js-course-form'),
|
||||
title = $form.find('.display-name').val(),
|
||||
key = $form.find('.course-key').val();
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
this.model.set({
|
||||
display_name: title,
|
||||
key: key,
|
||||
organization: this.programModel.get('organizations')[0]
|
||||
});
|
||||
|
||||
if (this.model.isValid(true)) {
|
||||
this.addCourseToProgram();
|
||||
this.updateDOM();
|
||||
this.addCourseRuns();
|
||||
}
|
||||
},
|
||||
|
||||
updateDOM: function() {
|
||||
HtmlUtils.setHtml(this.$el, this.tpl(this.formatData()));
|
||||
},
|
||||
|
||||
updateRuns: function() {
|
||||
var courseCodes = this.programModel.get('course_codes'),
|
||||
key = this.model.get('key'),
|
||||
name = this.model.get('display_name'),
|
||||
index;
|
||||
|
||||
if (this.programModel.isValid(true)) {
|
||||
index = _.findIndex(courseCodes, function(course) {
|
||||
return course.key === key && course.display_name === name;
|
||||
});
|
||||
courseCodes[index] = this.model.toJSON();
|
||||
|
||||
this.programModel.patch({course_codes: courseCodes});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -1,113 +0,0 @@
|
||||
define([
|
||||
'backbone',
|
||||
'jquery',
|
||||
'underscore',
|
||||
'text!templates/programs/course_run.underscore',
|
||||
'edx-ui-toolkit/js/utils/html-utils'
|
||||
],
|
||||
function(Backbone, $, _, CourseRunTpl, HtmlUtils) {
|
||||
'use strict';
|
||||
|
||||
return Backbone.View.extend({
|
||||
events: {
|
||||
'change .js-course-run-select': 'selectRun',
|
||||
'click .js-remove-run': 'removeRun'
|
||||
},
|
||||
|
||||
tpl: HtmlUtils.template(CourseRunTpl),
|
||||
|
||||
initialize: function(options) {
|
||||
/**
|
||||
* Need the run model for the template, and the courseModel
|
||||
* to keep parent view up to date with run changes
|
||||
*/
|
||||
this.courseModel = options.courseModel;
|
||||
this.courseRuns = options.courseRuns;
|
||||
this.programStatus = options.programStatus;
|
||||
|
||||
this.model.on('change', this.render, this);
|
||||
this.courseRuns.on('update', this.updateDropdown, this);
|
||||
|
||||
this.$parentEl = options.$parentEl;
|
||||
this.render();
|
||||
},
|
||||
|
||||
render: function() {
|
||||
var data = this.model.attributes;
|
||||
|
||||
data.programStatus = this.programStatus;
|
||||
|
||||
if (!!this.courseRuns) {
|
||||
data.courseRuns = this.courseRuns.toJSON();
|
||||
}
|
||||
|
||||
HtmlUtils.setHtml(this.$el, this.tpl(data));
|
||||
this.$parentEl.append(this.$el);
|
||||
},
|
||||
|
||||
// Delete this view
|
||||
destroy: function() {
|
||||
this.undelegateEvents();
|
||||
this.remove();
|
||||
},
|
||||
|
||||
// Data returned from courseList API is not the correct format
|
||||
formatData: function(data) {
|
||||
return {
|
||||
course_key: data.id,
|
||||
mode_slug: 'verified',
|
||||
start_date: data.start,
|
||||
sku: ''
|
||||
};
|
||||
},
|
||||
|
||||
removeRun: function() {
|
||||
// Update run_modes array on programModel
|
||||
var startDate = this.model.get('start_date'),
|
||||
courseKey = this.model.get('course_key'),
|
||||
/**
|
||||
* NB: cloning the array so the model will fire a change event when
|
||||
* the updated version is saved back to the model
|
||||
*/
|
||||
runs = _.clone(this.courseModel.get('run_modes')),
|
||||
updatedRuns = [];
|
||||
|
||||
updatedRuns = _.reject(runs, function(obj) {
|
||||
return obj.start_date === startDate &&
|
||||
obj.course_key === courseKey;
|
||||
});
|
||||
|
||||
this.courseModel.set({
|
||||
run_modes: updatedRuns
|
||||
});
|
||||
|
||||
this.courseRuns.addRun(courseKey);
|
||||
|
||||
this.destroy();
|
||||
},
|
||||
|
||||
selectRun: function(event) {
|
||||
var id = $(event.currentTarget).val(),
|
||||
runObj = _.findWhere(this.courseRuns.allRuns, {id: id}),
|
||||
/**
|
||||
* NB: cloning the array so the model will fire a change event when
|
||||
* the updated version is saved back to the model
|
||||
*/
|
||||
runs = _.clone(this.courseModel.get('run_modes')),
|
||||
data = this.formatData(runObj);
|
||||
|
||||
this.model.set(data);
|
||||
runs.push(data);
|
||||
this.courseModel.set({run_modes: runs});
|
||||
this.courseRuns.removeRun(id);
|
||||
},
|
||||
|
||||
// If a run has not been selected update the dropdown options
|
||||
updateDropdown: function() {
|
||||
if (!this.model.get('course_key')) {
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -1,68 +0,0 @@
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
define([
|
||||
'backbone',
|
||||
'js/programs/router',
|
||||
'js/programs/utils/api_config'
|
||||
],
|
||||
function(Backbone, ProgramRouter, apiConfig) {
|
||||
return Backbone.View.extend({
|
||||
el: '.js-program-admin',
|
||||
|
||||
events: {
|
||||
'click .js-app-click': 'navigate'
|
||||
},
|
||||
|
||||
initialize: function() {
|
||||
apiConfig.set({
|
||||
lmsBaseUrl: this.$el.data('lms-base-url'),
|
||||
programsApiUrl: this.$el.data('programs-api-url'),
|
||||
authUrl: this.$el.data('auth-url'),
|
||||
username: this.$el.data('username')
|
||||
});
|
||||
|
||||
this.app = new ProgramRouter({
|
||||
homeUrl: this.$el.data('home-url')
|
||||
});
|
||||
this.app.start();
|
||||
},
|
||||
|
||||
/**
|
||||
* Navigate to a new page within the app.
|
||||
*
|
||||
* Attempts to open the link in a new tab/window behave as the user expects, however the app
|
||||
* and data will be reloaded in the new tab/window.
|
||||
*
|
||||
* @param {Event} event - Event being handled.
|
||||
* @returns {boolean} - Indicates if event handling succeeded (always true).
|
||||
*/
|
||||
navigate: function(event) {
|
||||
var url = $(event.target).attr('href').replace(this.app.root, '');
|
||||
|
||||
/**
|
||||
* Handle the cases where the user wants to open the link in a new tab/window.
|
||||
* event.which === 2 checks for the middle mouse button (https://api.jquery.com/event.which/)
|
||||
*/
|
||||
if (event.ctrlKey || event.shiftKey || event.metaKey || event.which === 2) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// We'll take it from here...
|
||||
event.preventDefault();
|
||||
|
||||
// Process the navigation in the app/router.
|
||||
if (url === Backbone.history.getFragment() && url === '') {
|
||||
/**
|
||||
* Note: We must call the index directly since Backbone
|
||||
* does not support routing to the same route.
|
||||
*/
|
||||
this.app.index();
|
||||
} else {
|
||||
this.app.navigate(url, {trigger: true});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
})();
|
||||
@@ -1,112 +0,0 @@
|
||||
define([
|
||||
'backbone',
|
||||
'backbone.validation',
|
||||
'jquery',
|
||||
'underscore',
|
||||
'js/programs/models/organizations_model',
|
||||
'js/programs/models/program_model',
|
||||
'text!templates/programs/program_creator_form.underscore',
|
||||
'edx-ui-toolkit/js/utils/html-utils',
|
||||
'gettext',
|
||||
'js/programs/utils/validation_config'
|
||||
],
|
||||
function(Backbone, BackboneValidation, $, _, OrganizationsModel, ProgramModel, ListTpl, HtmlUtils) {
|
||||
'use strict';
|
||||
|
||||
return Backbone.View.extend({
|
||||
parentEl: '.js-program-admin',
|
||||
|
||||
events: {
|
||||
'click .js-create-program': 'createProgram',
|
||||
'click .js-abort-view': 'abort'
|
||||
},
|
||||
|
||||
tpl: HtmlUtils.template(ListTpl),
|
||||
|
||||
initialize: function(options) {
|
||||
this.$parentEl = $(this.parentEl);
|
||||
|
||||
this.model = new ProgramModel();
|
||||
this.model.on('sync', this.saveSuccess, this);
|
||||
this.model.on('error', this.saveError, this);
|
||||
|
||||
// Hook up validation.
|
||||
// See: http://thedersen.com/projects/backbone-validation/#validation-binding.
|
||||
Backbone.Validation.bind(this);
|
||||
|
||||
this.organizations = new OrganizationsModel();
|
||||
this.organizations.on('sync', this.render, this);
|
||||
this.organizations.fetch();
|
||||
|
||||
this.router = options.router;
|
||||
},
|
||||
|
||||
render: function() {
|
||||
HtmlUtils.setHtml(
|
||||
this.$el,
|
||||
this.tpl({
|
||||
orgs: this.organizations.get('results')
|
||||
})
|
||||
);
|
||||
|
||||
HtmlUtils.setHtml(this.$parentEl, HtmlUtils.HTML(this.$el));
|
||||
},
|
||||
|
||||
abort: function(event) {
|
||||
event.preventDefault();
|
||||
this.router.goHome();
|
||||
},
|
||||
|
||||
createProgram: function(event) {
|
||||
var data = this.getData();
|
||||
|
||||
event.preventDefault();
|
||||
this.model.set(data);
|
||||
|
||||
// Check if the model is valid before saving. Invalid attributes are looked
|
||||
// up by name. The corresponding elements receieve an `invalid` class and a
|
||||
// `data-error` attribute. Both are removed when formerly invalid attributes
|
||||
// become valid.
|
||||
// See: http://thedersen.com/projects/backbone-validation/#isvalid.
|
||||
if (this.model.isValid(true)) {
|
||||
this.model.save();
|
||||
}
|
||||
},
|
||||
|
||||
destroy: function() {
|
||||
// Unhook validation.
|
||||
// See: http://thedersen.com/projects/backbone-validation/#unbinding.
|
||||
Backbone.Validation.unbind(this);
|
||||
|
||||
this.undelegateEvents();
|
||||
this.remove();
|
||||
},
|
||||
|
||||
getData: function() {
|
||||
return {
|
||||
name: this.$el.find('.program-name').val(),
|
||||
subtitle: this.$el.find('.program-subtitle').val(),
|
||||
category: this.$el.find('.program-type').val(),
|
||||
marketing_slug: this.$el.find('.program-marketing-slug').val(),
|
||||
organizations: [{
|
||||
key: this.$el.find('.program-org').val()
|
||||
}]
|
||||
};
|
||||
},
|
||||
|
||||
goToView: function(uri) {
|
||||
Backbone.history.navigate(uri, {trigger: true});
|
||||
this.destroy();
|
||||
},
|
||||
|
||||
// TODO: add user messaging to show errors
|
||||
saveError: function(jqXHR) {
|
||||
console.log('saveError: ', jqXHR);
|
||||
},
|
||||
|
||||
saveSuccess: function() {
|
||||
this.goToView(String(this.model.get('id')));
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -1,206 +0,0 @@
|
||||
define([
|
||||
'backbone',
|
||||
'backbone.validation',
|
||||
'jquery',
|
||||
'underscore',
|
||||
'js/programs/collections/course_runs_collection',
|
||||
'js/programs/models/program_model',
|
||||
'js/programs/views/confirm_modal_view',
|
||||
'js/programs/views/course_details_view',
|
||||
'text!templates/programs/program_details.underscore',
|
||||
'edx-ui-toolkit/js/utils/html-utils',
|
||||
'gettext',
|
||||
'js/programs/utils/validation_config'
|
||||
],
|
||||
function(Backbone, BackboneValidation, $, _, CourseRunsCollection,
|
||||
ProgramModel, ModalView, CourseView, ListTpl,
|
||||
HtmlUtils) {
|
||||
'use strict';
|
||||
|
||||
return Backbone.View.extend({
|
||||
el: '.js-program-admin',
|
||||
|
||||
events: {
|
||||
'blur .js-inline-edit input': 'checkEdit',
|
||||
'click .js-add-course': 'addCourse',
|
||||
'click .js-enable-edit': 'editField',
|
||||
'click .js-publish-program': 'confirmPublish'
|
||||
},
|
||||
|
||||
tpl: HtmlUtils.template(ListTpl),
|
||||
|
||||
initialize: function() {
|
||||
Backbone.Validation.bind(this);
|
||||
|
||||
this.courseRuns = new CourseRunsCollection([], {
|
||||
organization: this.model.get('organizations')[0]
|
||||
});
|
||||
this.courseRuns.fetch();
|
||||
this.courseRuns.on('sync', this.setAvailableCourseRuns, this);
|
||||
this.render();
|
||||
},
|
||||
|
||||
render: function() {
|
||||
HtmlUtils.setHtml(this.$el, this.tpl(this.model.toJSON()));
|
||||
this.postRender();
|
||||
},
|
||||
|
||||
postRender: function() {
|
||||
var courses = this.model.get('course_codes');
|
||||
|
||||
_.each(courses, function(course) {
|
||||
var title = course.key + 'Course';
|
||||
|
||||
this[title] = new CourseView({
|
||||
courseRuns: this.courseRuns,
|
||||
programModel: this.model,
|
||||
courseData: course
|
||||
});
|
||||
}.bind(this));
|
||||
|
||||
// Stop listening to the model sync set when publishing
|
||||
this.model.off('sync');
|
||||
},
|
||||
|
||||
addCourse: function() {
|
||||
return new CourseView({
|
||||
courseRuns: this.courseRuns,
|
||||
programModel: this.model
|
||||
});
|
||||
},
|
||||
|
||||
checkEdit: function(event) {
|
||||
var $input = $(event.target),
|
||||
$span = $input.prevAll('.js-model-value'),
|
||||
$btn = $input.next('.js-enable-edit'),
|
||||
value = $input.val(),
|
||||
key = $input.data('field'),
|
||||
data = {};
|
||||
|
||||
data[key] = value;
|
||||
|
||||
$input.addClass('is-hidden');
|
||||
$btn.removeClass('is-hidden');
|
||||
$span.removeClass('is-hidden');
|
||||
|
||||
if (this.model.get(key) !== value) {
|
||||
this.model.set(data);
|
||||
|
||||
if (this.model.isValid(true)) {
|
||||
this.model.patch(data);
|
||||
$span.text(value);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Loads modal that user clicks a confirmation button
|
||||
* in to publish the course (or they can cancel out of it)
|
||||
*/
|
||||
confirmPublish: function(event) {
|
||||
event.preventDefault();
|
||||
|
||||
/**
|
||||
* Update validation to make marketing slug required
|
||||
* Note that because this validation is not required for
|
||||
* the program creation form and is only happening here
|
||||
* it makes sense to have the validation at the view level
|
||||
*/
|
||||
if (this.model.isValid(true) && this.validateMarketingSlug()) {
|
||||
this.modalView = new ModalView({
|
||||
model: this.model,
|
||||
callback: _.bind(this.publishProgram, this),
|
||||
content: this.getModalContent(),
|
||||
parentEl: '.js-publish-modal',
|
||||
parentView: this
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
editField: function(event) {
|
||||
/**
|
||||
* Making the assumption that users can only see
|
||||
* programs that they have permission to edit
|
||||
*/
|
||||
var $btn = $(event.currentTarget),
|
||||
$el = $btn.prev('input');
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
$el.prevAll('.js-model-value').addClass('is-hidden');
|
||||
$el.removeClass('is-hidden')
|
||||
.addClass('edit')
|
||||
.focus();
|
||||
$btn.addClass('is-hidden');
|
||||
},
|
||||
|
||||
getModalContent: function() {
|
||||
return {
|
||||
name: gettext('confirm'),
|
||||
title: gettext('Publish this program?'),
|
||||
body: gettext(
|
||||
'After you publish this program, you cannot add or remove course codes or remove course runs.'
|
||||
),
|
||||
cta: {
|
||||
cancel: gettext('Cancel'),
|
||||
confirm: gettext('Publish')
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
publishProgram: function() {
|
||||
var data = {
|
||||
status: 'active'
|
||||
};
|
||||
|
||||
this.model.set(data, {silent: true});
|
||||
this.model.on('sync', this.render, this);
|
||||
this.model.patch(data);
|
||||
},
|
||||
|
||||
setAvailableCourseRuns: function() {
|
||||
var allRuns = this.courseRuns.toJSON(),
|
||||
courses = this.model.get('course_codes'),
|
||||
selectedRuns,
|
||||
availableRuns = allRuns;
|
||||
|
||||
if (courses.length) {
|
||||
selectedRuns = _.pluck(courses, 'run_modes');
|
||||
selectedRuns = _.flatten(selectedRuns);
|
||||
}
|
||||
|
||||
availableRuns = _.reject(allRuns, function(run) {
|
||||
var selectedCourseRun = _.findWhere(selectedRuns, {
|
||||
course_key: run.id,
|
||||
start_date: run.start
|
||||
});
|
||||
|
||||
return !_.isUndefined(selectedCourseRun);
|
||||
});
|
||||
|
||||
this.courseRuns.set(availableRuns);
|
||||
},
|
||||
|
||||
validateMarketingSlug: function() {
|
||||
var isValid = false,
|
||||
$input = {},
|
||||
$message = {};
|
||||
|
||||
if (this.model.get('marketing_slug').length > 0) {
|
||||
isValid = true;
|
||||
} else {
|
||||
$input = this.$el.find('#program-marketing-slug');
|
||||
$message = $input.siblings('.field-message');
|
||||
|
||||
// Update DOM
|
||||
$input.addClass('has-error');
|
||||
$message.addClass('has-error');
|
||||
$message.find('.field-message-content')
|
||||
.text(gettext('Marketing Slug is required.'));
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -1,108 +0,0 @@
|
||||
define([
|
||||
'underscore',
|
||||
'backbone',
|
||||
'jquery',
|
||||
'js/programs/utils/api_config',
|
||||
'js/programs/models/auto_auth_model'
|
||||
],
|
||||
function(_, Backbone, $, apiConfig, AutoAuthModel) {
|
||||
'use strict';
|
||||
|
||||
describe('AutoAuthModel', function() {
|
||||
var model,
|
||||
testErrorCallback,
|
||||
fakeAjaxDeferred,
|
||||
spyOnBackboneSync,
|
||||
callSync,
|
||||
checkAuthAttempted,
|
||||
dummyModel = {'dummy': 'model'},
|
||||
authUrl = apiConfig.get('authUrl');
|
||||
|
||||
beforeEach(function() {
|
||||
// instance under test
|
||||
model = new AutoAuthModel();
|
||||
|
||||
// stand-in for the error callback a caller might pass with options to Backbone.Model.sync
|
||||
testErrorCallback = jasmine.createSpy();
|
||||
|
||||
fakeAjaxDeferred = $.Deferred();
|
||||
spyOn($, 'ajax').and.returnValue(fakeAjaxDeferred);
|
||||
return fakeAjaxDeferred;
|
||||
});
|
||||
|
||||
spyOnBackboneSync = function(status) {
|
||||
// set up Backbone.sync to invoke its error callback with the desired HTTP status
|
||||
spyOn(Backbone, 'sync').and.callFake(function(method, model, options) {
|
||||
var fakeXhr = options.xhr = {status: status};
|
||||
options.error(fakeXhr, 0, '');
|
||||
});
|
||||
};
|
||||
|
||||
callSync = function(options) {
|
||||
var params,
|
||||
syncOptions = _.extend({error: testErrorCallback}, options || {});
|
||||
|
||||
model.sync('GET', dummyModel, syncOptions);
|
||||
|
||||
// make sure Backbone.sync was called with custom error handling
|
||||
expect(Backbone.sync.calls.count()).toEqual(1);
|
||||
params = _.object(['method', 'model', 'options'], Backbone.sync.calls.mostRecent().args);
|
||||
expect(params.method).toEqual('GET');
|
||||
expect(params.model).toEqual(dummyModel);
|
||||
expect(params.options.error).not.toEqual(testErrorCallback);
|
||||
return params;
|
||||
};
|
||||
|
||||
checkAuthAttempted = function(isExpected) {
|
||||
if (isExpected) {
|
||||
expect($.ajax).toHaveBeenCalled();
|
||||
expect($.ajax.calls.mostRecent().args[0].url).toEqual(authUrl);
|
||||
} else {
|
||||
expect($.ajax).not.toHaveBeenCalled();
|
||||
}
|
||||
};
|
||||
|
||||
it('should exist', function() {
|
||||
expect(model).toBeDefined();
|
||||
});
|
||||
|
||||
it('should intercept 401 errors and attempt auth', function() {
|
||||
var callParams;
|
||||
|
||||
spyOnBackboneSync(401);
|
||||
|
||||
callSync();
|
||||
|
||||
// make sure the auth attempt was initiated
|
||||
checkAuthAttempted(true);
|
||||
|
||||
// fire the success handler for the fake ajax call, with id token response data
|
||||
fakeAjaxDeferred.resolve({id_token: 'test-id-token'});
|
||||
|
||||
// make sure the original request was retried with token, and without custom error handling
|
||||
expect(Backbone.sync.calls.count()).toEqual(2);
|
||||
callParams = _.object(['method', 'model', 'options'], Backbone.sync.calls.mostRecent().args);
|
||||
expect(callParams.method).toEqual('GET');
|
||||
expect(callParams.model).toEqual(dummyModel);
|
||||
expect(callParams.options.error).toEqual(testErrorCallback);
|
||||
expect(callParams.options.headers.Authorization).toEqual('JWT test-id-token');
|
||||
});
|
||||
|
||||
it('should not intercept non-401 errors', function() {
|
||||
spyOnBackboneSync(403);
|
||||
|
||||
// invoke AutoAuthModel.sync
|
||||
callSync();
|
||||
|
||||
// make sure NO auth attempt was initiated
|
||||
checkAuthAttempted(false);
|
||||
|
||||
// make sure the original request was not retried
|
||||
expect(Backbone.sync.calls.count()).toEqual(1);
|
||||
|
||||
// make sure the default error handling was invoked
|
||||
expect(testErrorCallback).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -1,252 +0,0 @@
|
||||
define([
|
||||
'backbone',
|
||||
'jquery',
|
||||
'js/programs/views/program_creator_view'
|
||||
],
|
||||
function(Backbone, $, ProgramCreatorView) {
|
||||
'use strict';
|
||||
|
||||
describe('ProgramCreatorView', function() {
|
||||
var view = {},
|
||||
Router = Backbone.Router.extend({
|
||||
initialize: function(options) {
|
||||
this.homeUrl = options.homeUrl;
|
||||
},
|
||||
goHome: function() {
|
||||
window.location.href = this.homeUrl;
|
||||
}
|
||||
}),
|
||||
organizations = {
|
||||
count: 1,
|
||||
previous: null,
|
||||
'num_pages': 1,
|
||||
results: [{
|
||||
'display_name': 'test-org-display_name',
|
||||
'key': 'test-org-key'
|
||||
}],
|
||||
next: null
|
||||
},
|
||||
sampleInput,
|
||||
completeForm = function(data) {
|
||||
view.$el.find('#program-name').val(data.name);
|
||||
view.$el.find('#program-subtitle').val(data.subtitle);
|
||||
view.$el.find('#program-org').val(data.organizations);
|
||||
|
||||
if (data.category) {
|
||||
view.$el.find('#program-type').val(data.category);
|
||||
}
|
||||
|
||||
if (data.marketing_slug) {
|
||||
view.$el.find('#program-marketing-slug').val(data.marketing_slug);
|
||||
}
|
||||
},
|
||||
verifyValidation = function(data, invalidAttr) {
|
||||
var errorClass = 'has-error',
|
||||
$invalidElement = view.$el.find('[name="' + invalidAttr + '"]'),
|
||||
$errorMsg = $invalidElement.siblings('.field-message'),
|
||||
inputErrorMsg = '';
|
||||
|
||||
completeForm(data);
|
||||
|
||||
view.$el.find('.js-create-program').click();
|
||||
inputErrorMsg = $invalidElement.data('error');
|
||||
|
||||
expect(view.model.save).not.toHaveBeenCalled();
|
||||
expect($invalidElement).toHaveClass(errorClass);
|
||||
expect($errorMsg).toHaveClass(errorClass);
|
||||
expect(inputErrorMsg).toBeDefined();
|
||||
expect($errorMsg.find('.field-message-content').html()).toEqual(inputErrorMsg);
|
||||
};
|
||||
|
||||
var validateFormSubmitted = function(view, programId) {
|
||||
expect($.ajax).toHaveBeenCalled();
|
||||
expect(view.saveSuccess).toHaveBeenCalled();
|
||||
expect(view.goToView).toHaveBeenCalledWith(String(programId));
|
||||
expect(view.saveError).not.toHaveBeenCalled();
|
||||
};
|
||||
|
||||
beforeEach(function() {
|
||||
// Set the DOM
|
||||
setFixtures('<div class="js-program-admin"></div>');
|
||||
|
||||
jasmine.clock().install();
|
||||
|
||||
spyOn(ProgramCreatorView.prototype, 'saveSuccess').and.callThrough();
|
||||
spyOn(ProgramCreatorView.prototype, 'goToView').and.callThrough();
|
||||
spyOn(ProgramCreatorView.prototype, 'saveError').and.callThrough();
|
||||
spyOn(Router.prototype, 'goHome');
|
||||
|
||||
sampleInput = {
|
||||
category: 'XSeries',
|
||||
organizations: 'test-org-key',
|
||||
name: 'Test Course Name',
|
||||
subtitle: 'Test Course Subtitle',
|
||||
marketing_slug: 'test-management'
|
||||
};
|
||||
|
||||
view = new ProgramCreatorView({
|
||||
router: new Router({
|
||||
homeUrl: '/author/home'
|
||||
})
|
||||
});
|
||||
|
||||
view.organizations.set(organizations);
|
||||
view.render();
|
||||
});
|
||||
|
||||
afterEach(function() {
|
||||
view.destroy();
|
||||
|
||||
jasmine.clock().uninstall();
|
||||
});
|
||||
|
||||
it('should exist', function() {
|
||||
expect(view).toBeDefined();
|
||||
});
|
||||
|
||||
it('should get the form data', function() {
|
||||
var formData = {};
|
||||
|
||||
completeForm(sampleInput);
|
||||
formData = view.getData();
|
||||
|
||||
expect(formData.name).toEqual(sampleInput.name);
|
||||
expect(formData.subtitle).toEqual(sampleInput.subtitle);
|
||||
expect(formData.organizations[0].key).toEqual(sampleInput.organizations);
|
||||
});
|
||||
|
||||
it('should submit the form when the user clicks submit', function() {
|
||||
var programId = 123;
|
||||
|
||||
completeForm(sampleInput);
|
||||
|
||||
spyOn($, 'ajax').and.callFake(function(event) {
|
||||
event.success({id: programId});
|
||||
});
|
||||
|
||||
view.$el.find('.js-create-program').click();
|
||||
|
||||
validateFormSubmitted(view, programId);
|
||||
});
|
||||
|
||||
it('should submit the form correctly when creating micromasters program ', function() {
|
||||
var programId = 221;
|
||||
sampleInput.category = 'MicroMasters';
|
||||
|
||||
completeForm(sampleInput);
|
||||
|
||||
spyOn($, 'ajax').and.callFake(function(event) {
|
||||
event.success({id: programId});
|
||||
});
|
||||
|
||||
view.$el.find('.js-create-program').click();
|
||||
|
||||
validateFormSubmitted(view, programId);
|
||||
});
|
||||
|
||||
it('should run the saveError when model save failures occur', function() {
|
||||
spyOn($, 'ajax').and.callFake(function(event) {
|
||||
event.error();
|
||||
});
|
||||
|
||||
// Fill out the form with valid data so that form model validation doesn't
|
||||
// prevent the model from being saved.
|
||||
completeForm(sampleInput);
|
||||
view.$el.find('.js-create-program').click();
|
||||
|
||||
expect($.ajax).toHaveBeenCalled();
|
||||
expect(view.saveSuccess).not.toHaveBeenCalled();
|
||||
expect(view.saveError).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should set the model when valid form data is submitted', function() {
|
||||
completeForm(sampleInput);
|
||||
|
||||
spyOn($, 'ajax').and.callFake(function(event) {
|
||||
event.success({id: 10001110101});
|
||||
});
|
||||
|
||||
view.$el.find('.js-create-program').click();
|
||||
|
||||
expect(view.model.get('name')).toEqual(sampleInput.name);
|
||||
expect(view.model.get('subtitle')).toEqual(sampleInput.subtitle);
|
||||
expect(view.model.get('organizations')[0].key).toEqual(sampleInput.organizations);
|
||||
expect(view.model.get('marketing_slug')).toEqual(sampleInput.marketing_slug);
|
||||
});
|
||||
|
||||
it('should not set the model when bad program type selected', function() {
|
||||
var invalidInput = $.extend({}, sampleInput);
|
||||
spyOn(view.model, 'save');
|
||||
|
||||
// No name provided.
|
||||
invalidInput.category = '';
|
||||
verifyValidation(invalidInput, 'category');
|
||||
|
||||
// bad program type name
|
||||
invalidInput.name = 'badprogramtype';
|
||||
verifyValidation(invalidInput, 'category');
|
||||
});
|
||||
|
||||
it('should not set the model when an invalid program name is submitted', function() {
|
||||
var invalidInput = $.extend({}, sampleInput);
|
||||
|
||||
spyOn(view.model, 'save');
|
||||
|
||||
// No name provided.
|
||||
invalidInput.name = '';
|
||||
verifyValidation(invalidInput, 'name');
|
||||
|
||||
// Name is too long.
|
||||
invalidInput.name = 'x'.repeat(256);
|
||||
verifyValidation(invalidInput, 'name');
|
||||
});
|
||||
|
||||
it('should not set the model when an invalid program subtitle is submitted', function() {
|
||||
var invalidInput = $.extend({}, sampleInput);
|
||||
|
||||
spyOn(view.model, 'save');
|
||||
|
||||
// Subtitle is too long.
|
||||
invalidInput.subtitle = 'x'.repeat(300);
|
||||
verifyValidation(invalidInput, 'subtitle');
|
||||
});
|
||||
|
||||
it('should not set the model when an invalid category is submitted', function() {
|
||||
var invalidInput = $.extend({}, sampleInput);
|
||||
|
||||
spyOn(view.model, 'save');
|
||||
|
||||
// Category other than 'xseries' selected.
|
||||
invalidInput.category = 'yseries';
|
||||
verifyValidation(invalidInput, 'category');
|
||||
});
|
||||
|
||||
it('should not set the model when an invalid organization key is submitted', function() {
|
||||
var invalidInput = $.extend({}, sampleInput);
|
||||
|
||||
spyOn(view.model, 'save');
|
||||
|
||||
// No organization selected.
|
||||
invalidInput.organizations = 'false';
|
||||
verifyValidation(invalidInput, 'organizations');
|
||||
});
|
||||
|
||||
it('should not set the model when an invalid marketing slug is submitted', function() {
|
||||
var invalidInput = $.extend({}, sampleInput);
|
||||
|
||||
spyOn(view.model, 'save');
|
||||
|
||||
// Marketing slug is too long.
|
||||
invalidInput.marketing_slug = 'x'.repeat(256);
|
||||
verifyValidation(invalidInput, 'marketing_slug');
|
||||
});
|
||||
|
||||
it('should abort the view when the cancel button is clicked', function() {
|
||||
completeForm(sampleInput);
|
||||
expect(view.$parentEl.html().length).toBeGreaterThan(0);
|
||||
view.$el.find('.js-abort-view').click();
|
||||
expect(view.router.goHome).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -1,532 +0,0 @@
|
||||
define([
|
||||
'jquery',
|
||||
'js/programs/collections/course_runs_collection',
|
||||
'js/programs/models/program_model',
|
||||
'js/programs/views/course_run_view',
|
||||
'js/programs/views/program_details_view',
|
||||
'js/programs/utils/constants'
|
||||
],
|
||||
function($, CourseRunsCollection, ProgramModel, CourseRunView,
|
||||
ProgramDetailsView, constants) {
|
||||
'use strict';
|
||||
|
||||
describe('ProgramDetailsView', function() {
|
||||
var view = {},
|
||||
model = {},
|
||||
courseRunsList = [
|
||||
{
|
||||
id: 'course-v1:edX+DemoX+Demo_Course',
|
||||
name: 'edX Demonstration Course',
|
||||
number: 'DemoX',
|
||||
org: 'edX',
|
||||
short_description: null,
|
||||
effort: null,
|
||||
media: {
|
||||
course_image: {
|
||||
uri: '/asset-v1:edX+DemoX+Demo_Course+type@asset+block@images_course_image.jpg'
|
||||
},
|
||||
course_video: {
|
||||
uri: null
|
||||
}
|
||||
},
|
||||
start: 'May 23, 2015',
|
||||
start_type: 'timestamp',
|
||||
start_display: 'Feb. 5, 2013',
|
||||
end: null,
|
||||
enrollment_start: null,
|
||||
enrollment_end: null,
|
||||
blocks_url: 'http://127.0.0.1:8000/api/courses/v1/blocks/?course_id=course-v1%3AedX%2BDemoX%2BDemo_Course' // eslint-disable-line max-len
|
||||
},
|
||||
{
|
||||
id: 'course-v1:edx+Krampus25+2015_12_05',
|
||||
name: 'Krampusnacht',
|
||||
number: 'Krampus25',
|
||||
org: 'edx',
|
||||
short_description: null,
|
||||
effort: null,
|
||||
media: {
|
||||
course_image: {
|
||||
uri: '/asset-v1:edx+Krampus25+2015_12_05+type@asset+block@images_course_image.jpg'
|
||||
},
|
||||
course_video: {
|
||||
uri: null
|
||||
}
|
||||
},
|
||||
start: '2030-01-01T00:00:00Z',
|
||||
start_type: 'empty',
|
||||
start_display: null,
|
||||
end: null,
|
||||
enrollment_start: null,
|
||||
enrollment_end: null,
|
||||
blocks_url: 'http://127.0.0.1:8000/api/courses/v1/blocks/?course_id=course-v1%3Aedx%2BKrampus25%2B2015_12_05' // eslint-disable-line max-len
|
||||
},
|
||||
{
|
||||
id: 'course-v1:edx+shiaLB101+2016_01',
|
||||
name: 'Shia "The Beef"',
|
||||
number: 'shiaLB101',
|
||||
org: 'edx',
|
||||
short_description: null,
|
||||
effort: null,
|
||||
media: {
|
||||
course_image: {
|
||||
uri: '/asset-v1:edx+shiaLB101+2016_01+type@asset+block@images_course_image.jpg'
|
||||
},
|
||||
course_video: {
|
||||
uri: null
|
||||
}
|
||||
},
|
||||
start: '2030-01-01T00:00:00Z',
|
||||
start_type: 'empty',
|
||||
start_display: null,
|
||||
end: null,
|
||||
enrollment_start: null,
|
||||
enrollment_end: null,
|
||||
blocks_url: 'http://127.0.0.1:8000/api/courses/v1/blocks/?course_id=course-v1%3Aedx%2BshiaLB101%2B2016_01' // eslint-disable-line max-len
|
||||
}
|
||||
],
|
||||
programData = {
|
||||
category: 'XSeries',
|
||||
course_codes: [{
|
||||
display_name: 'test-course-display_name',
|
||||
key: 'test-course-key',
|
||||
organization: {
|
||||
display_name: 'test-org-display_name',
|
||||
key: 'test-org-key'
|
||||
},
|
||||
run_modes: [
|
||||
{
|
||||
course_key: 'course-v1:edX+DemoX+Demo_Course',
|
||||
mode_slug: 'honor',
|
||||
sku: null,
|
||||
start: 'May 23, 2015'
|
||||
}, {
|
||||
course_key: 'course-v1:edX+DemoX+Demo_Course',
|
||||
mode_slug: 'honor',
|
||||
sku: null,
|
||||
start: 'August 01, 2015'
|
||||
}, {
|
||||
course_key: 'course-v1:edX+DemoX+Demo_Course',
|
||||
mode_slug: 'honor',
|
||||
sku: null,
|
||||
start: 'December 11, 2015'
|
||||
}
|
||||
]
|
||||
}],
|
||||
created: '2015-10-20T18:11:46.854451Z',
|
||||
id: 5,
|
||||
marketing_slug: 'test-program-slug',
|
||||
modified: '2015-10-20T18:11:46.854735Z',
|
||||
name: 'test-program-5',
|
||||
organizations: [{
|
||||
display_name: 'test-org-display_name',
|
||||
key: 'test-org-key'
|
||||
}],
|
||||
status: 'unpublished',
|
||||
subtitle: 'test-subtitle'
|
||||
},
|
||||
testTimeoutInterval = 100,
|
||||
errorClass = 'has-error',
|
||||
addCourse,
|
||||
completeCourseForm,
|
||||
dropdownSelect,
|
||||
editField,
|
||||
keyPress,
|
||||
openPublishModal,
|
||||
resetCourseCodes,
|
||||
testHidingButtonsAfterPublish,
|
||||
testInvalidUpdate,
|
||||
testUnchangedFieldBlur,
|
||||
testUpdatedFieldBlur;
|
||||
|
||||
addCourse = function() {
|
||||
var $addCourseBtn = view.$el.find('.js-add-course').first(),
|
||||
$form,
|
||||
$submitBtn;
|
||||
|
||||
expect(view.$el.find('.course-details').length).toEqual(1);
|
||||
$addCourseBtn.click();
|
||||
$form = view.$('.js-course-form');
|
||||
$submitBtn = $form.find('.js-select-course');
|
||||
completeCourseForm();
|
||||
$submitBtn.click();
|
||||
expect($form.find('.field-message.has-error').length).toEqual(0);
|
||||
};
|
||||
|
||||
completeCourseForm = function() {
|
||||
var $form = view.$('.js-course-form');
|
||||
|
||||
$form.find('.course-key').val('123');
|
||||
$form.find('.display-name').val('test course 1');
|
||||
};
|
||||
|
||||
dropdownSelect = function($select, value) {
|
||||
$select.find('option:selected').prop('selected', false);
|
||||
$select.val(value).prop('selected', true);
|
||||
$select.trigger('change');
|
||||
};
|
||||
|
||||
editField = function(el, str) {
|
||||
var $input = view.$el.find(el),
|
||||
$btn = $input.next('.js-enable-edit');
|
||||
|
||||
expect(document.activeElement).not.toEqual($input[0]);
|
||||
expect($input).not.toHaveClass('edit');
|
||||
expect($input).toHaveClass('is-hidden');
|
||||
|
||||
$btn.click();
|
||||
|
||||
$input.val(str);
|
||||
|
||||
// Enable editing
|
||||
expect($input).not.toHaveClass('is-hidden');
|
||||
expect($input).toHaveClass('edit');
|
||||
};
|
||||
|
||||
keyPress = function($el, key) {
|
||||
$el.trigger({
|
||||
type: 'keydown',
|
||||
keyCode: key,
|
||||
which: key,
|
||||
charCode: key
|
||||
});
|
||||
};
|
||||
|
||||
openPublishModal = function() {
|
||||
var $publishBtn = view.$el.find('.js-publish-program'),
|
||||
defaultStatus = programData.status,
|
||||
publishedStatus = 'active';
|
||||
|
||||
expect(view.modalView).not.toBeDefined();
|
||||
expect(view.model.get('status')).toEqual(defaultStatus);
|
||||
expect(view.model.get('status')).not.toEqual(publishedStatus);
|
||||
|
||||
$publishBtn.click();
|
||||
};
|
||||
|
||||
resetCourseCodes = function() {
|
||||
var originalRun = programData.course_codes[0];
|
||||
|
||||
programData.course_codes = [originalRun];
|
||||
};
|
||||
|
||||
testUnchangedFieldBlur = function(el) {
|
||||
var $input = view.$el.find(el),
|
||||
$btn = view.$el.find('.js-add-course'),
|
||||
title = $input.val(),
|
||||
update = title;
|
||||
|
||||
editField(el, update);
|
||||
$btn.focus();
|
||||
$input.blur();
|
||||
|
||||
expect(title).toEqual(update);
|
||||
expect(view.model.save).not.toHaveBeenCalled();
|
||||
};
|
||||
|
||||
testUpdatedFieldBlur = function(el, update) {
|
||||
var $input = view.$el.find(el),
|
||||
$btn = view.$el.find('.js-add-course');
|
||||
|
||||
expect($input.val()).not.toEqual(update);
|
||||
|
||||
editField(el, update);
|
||||
|
||||
$btn.focus();
|
||||
$input.blur();
|
||||
|
||||
expect($input.val()).toEqual(update);
|
||||
expect(view.model.save).toHaveBeenCalled();
|
||||
};
|
||||
|
||||
testHidingButtonsAfterPublish = function(el) {
|
||||
expect(view.$el.find(el).length).toBeGreaterThan(0);
|
||||
view.model.set({status: 'active'});
|
||||
view.render();
|
||||
expect(view.$el.find(el).length).toEqual(0);
|
||||
};
|
||||
|
||||
testInvalidUpdate = function(el, update) {
|
||||
var $input = view.$el.find(el),
|
||||
$btn = view.$el.find('.js-add-course');
|
||||
|
||||
editField(el, update);
|
||||
|
||||
$btn.focus();
|
||||
$input.blur();
|
||||
|
||||
expect($input).toHaveClass(errorClass);
|
||||
expect(view.model.save).not.toHaveBeenCalled();
|
||||
};
|
||||
|
||||
beforeEach(function() {
|
||||
// Set the DOM
|
||||
setFixtures('<div class="js-program-admin"></div>');
|
||||
|
||||
jasmine.clock().install();
|
||||
|
||||
spyOn(ProgramModel.prototype, 'set').and.callThrough();
|
||||
spyOn(ProgramModel.prototype, 'save');
|
||||
spyOn(CourseRunsCollection.prototype, 'fetch');
|
||||
|
||||
model = new ProgramModel();
|
||||
model.set(programData);
|
||||
|
||||
view = new ProgramDetailsView({
|
||||
model: model
|
||||
});
|
||||
|
||||
view.courseRuns.set(courseRunsList);
|
||||
view.courseRuns.parse({results: courseRunsList});
|
||||
});
|
||||
|
||||
afterEach(function() {
|
||||
resetCourseCodes();
|
||||
view.undelegateEvents();
|
||||
view.remove();
|
||||
|
||||
jasmine.clock().uninstall();
|
||||
});
|
||||
|
||||
describe('View data', function() {
|
||||
it('should exist', function() {
|
||||
expect(view).toBeDefined();
|
||||
});
|
||||
|
||||
it('should render all of the run_modes from the model', function() {
|
||||
var $runs = view.$el.find('.js-course-runs'),
|
||||
domLength = $runs.find('.js-remove-run').length,
|
||||
objLength = programData.course_codes[0].run_modes.length;
|
||||
|
||||
expect(domLength).toEqual(objLength);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Delete data', function() {
|
||||
it('should remove a course when the delete button is clicked', function() {
|
||||
var $el = view.$el.find('.js-course-list'),
|
||||
$removeRunBtn = $el.find('.js-remove-course').first(),
|
||||
count = programData.course_codes.length;
|
||||
|
||||
expect($el.find('.js-remove-course').length).toEqual(count);
|
||||
$removeRunBtn.click();
|
||||
|
||||
setTimeout(function() {
|
||||
expect($el.find('.js-remove-course').length).toEqual(count - 1);
|
||||
}, testTimeoutInterval);
|
||||
|
||||
jasmine.clock().tick(testTimeoutInterval + 1);
|
||||
});
|
||||
|
||||
it('should remove a course run when the delete button is clicked', function() {
|
||||
var $runs = view.$el.find('.js-course-runs'),
|
||||
$removeRunBtn = $runs.find('.js-remove-run').first(),
|
||||
count = programData.course_codes[0].run_modes.length;
|
||||
|
||||
expect($runs.find('.js-remove-run').length).toEqual(count);
|
||||
$removeRunBtn.click();
|
||||
|
||||
setTimeout(function() {
|
||||
expect($runs.find('.js-remove-run').length).toEqual(count - 1);
|
||||
}, testTimeoutInterval);
|
||||
|
||||
jasmine.clock().tick(testTimeoutInterval + 1);
|
||||
});
|
||||
|
||||
it('should not show the delete course button if program status is not unpublished', function() {
|
||||
testHidingButtonsAfterPublish('.js-remove-course');
|
||||
});
|
||||
|
||||
it('should not show the add course button if program status is not unpublished', function() {
|
||||
testHidingButtonsAfterPublish('.js-add-course');
|
||||
});
|
||||
|
||||
it('should not show the delete run button if program status is not unpublished', function() {
|
||||
testHidingButtonsAfterPublish('.js-remove-run');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Add data', function() {
|
||||
it('should add a new course details view on click of the add course button', function() {
|
||||
var $btn = view.$el.find('.js-add-course').first();
|
||||
|
||||
expect(view.$('.js-course-form').length).toEqual(0);
|
||||
$btn.click();
|
||||
expect(view.$('.js-course-form').length).toEqual(1);
|
||||
});
|
||||
|
||||
it('should add a course when the form is submitted', function() {
|
||||
addCourse();
|
||||
});
|
||||
|
||||
it('should not submit the course form when it is incomplete', function() {
|
||||
var $addCourseBtn = view.$el.find('.js-add-course').first(),
|
||||
$form,
|
||||
$submitBtn;
|
||||
|
||||
expect(view.$el.find('.course-details').length).toEqual(1);
|
||||
$addCourseBtn.click();
|
||||
$form = view.$('.js-course-form');
|
||||
expect($form.find('.field-message.has-error').length).toEqual(0);
|
||||
$submitBtn = $form.find('.js-select-course');
|
||||
$submitBtn.click();
|
||||
expect($form.find('.field-message.has-error').length).toEqual(2);
|
||||
});
|
||||
|
||||
it('should allow a user to add a course run', function() {
|
||||
var runSelect = '.js-course-run-select',
|
||||
$runSelect,
|
||||
savedRunCount = programData.course_codes[0].run_modes.length;
|
||||
|
||||
addCourse();
|
||||
expect(view.$(runSelect).length).toEqual(0);
|
||||
view.$('.js-add-course-run').first().click();
|
||||
|
||||
$runSelect = view.$(runSelect);
|
||||
expect($runSelect.length).toEqual(1);
|
||||
expect(view.$('.js-remove-run').length).toEqual(savedRunCount);
|
||||
dropdownSelect($runSelect, courseRunsList[0].id);
|
||||
expect(view.$(runSelect).length).toEqual(0);
|
||||
expect(view.$('.js-remove-run').length).toEqual(savedRunCount + 1);
|
||||
});
|
||||
|
||||
it('should update the course run dropdown if multiple are open and one is selected', function() {
|
||||
var runSelect = '.js-course-run-select',
|
||||
$addRunBtn,
|
||||
$courseView,
|
||||
courseRunOptionsCount = courseRunsList.length + 1;
|
||||
|
||||
addCourse();
|
||||
expect(view.$(runSelect).length).toEqual(0);
|
||||
|
||||
$courseView = view.$('.course-container').last();
|
||||
$addRunBtn = $courseView.find('.js-add-course-run');
|
||||
$addRunBtn.click();
|
||||
|
||||
expect(view.$(runSelect).length).toEqual(1);
|
||||
expect(view.$(runSelect).find('option').length).toEqual(courseRunOptionsCount);
|
||||
|
||||
$addRunBtn.click();
|
||||
expect(view.$(runSelect).length).toEqual(2);
|
||||
|
||||
dropdownSelect(view.$(runSelect).first(), courseRunsList[0].id);
|
||||
expect(view.$(runSelect).length).toEqual(1);
|
||||
expect(view.$(runSelect).find('option').length).toEqual(courseRunOptionsCount - 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edit data', function() {
|
||||
it('should enable a user to edit the name, subtitle and marketing slug fields', function() {
|
||||
editField('.program-name', 'name');
|
||||
editField('.program-subtitle', 'subtitle');
|
||||
editField('.program-marketing-slug', 'marketing-slug');
|
||||
});
|
||||
|
||||
it('should not send an API call if a user does not change the value of an editable field', function() {
|
||||
testUnchangedFieldBlur('.program-name');
|
||||
testUnchangedFieldBlur('.program-subtitle');
|
||||
testUnchangedFieldBlur('.program-marketing-slug');
|
||||
});
|
||||
|
||||
it('should send an API call if a user changes the value of an editable field', function() {
|
||||
testUpdatedFieldBlur('.program-name', 'new-title');
|
||||
testUpdatedFieldBlur('.program-subtitle', 'new-subtitle');
|
||||
testUpdatedFieldBlur('.program-marketing-slug', 'new-marketing-slug');
|
||||
});
|
||||
|
||||
it('should show error messaging if the updated required field is empty', function() {
|
||||
testInvalidUpdate('.program-name', '');
|
||||
});
|
||||
|
||||
it('should show error messaging if the updated field value is too long', function() {
|
||||
var chars256 = 'x'.repeat(256);
|
||||
|
||||
testInvalidUpdate('.program-name', chars256);
|
||||
testInvalidUpdate('.program-subtitle', chars256);
|
||||
testInvalidUpdate('.program-marketing-slug', chars256);
|
||||
});
|
||||
|
||||
it('should create a POST config object by default', function() {
|
||||
var config = view.model.getConfig();
|
||||
|
||||
expect(config.type).toEqual('POST');
|
||||
expect(config.contentType).toEqual('application/json');
|
||||
expect(config.data).not.toBeDefined();
|
||||
});
|
||||
|
||||
it('should create a PATCH config object when passed in object sets patch as true', function() {
|
||||
var data = {name: 'patched name'},
|
||||
config = view.model.getConfig({
|
||||
patch: true,
|
||||
update: data
|
||||
});
|
||||
|
||||
expect(config.type).toEqual('PATCH');
|
||||
expect(config.contentType).toEqual('application/merge-patch+json');
|
||||
expect(config.data).toBeDefined();
|
||||
expect(config.data).toEqual(JSON.stringify(data));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Publish a Program', function() {
|
||||
it('should open the publish modal when the publish button is clicked', function() {
|
||||
openPublishModal();
|
||||
expect(view.modalView).toBeDefined();
|
||||
});
|
||||
|
||||
it('should publish a program when the publish confirm button is clicked', function() {
|
||||
var defaultStatus = programData.status,
|
||||
publishedStatus = 'active';
|
||||
|
||||
openPublishModal();
|
||||
expect(view.modalView).toBeDefined();
|
||||
|
||||
view.$el.find('.js-confirm').click();
|
||||
|
||||
// Model should be set and save called
|
||||
expect(view.model.set).toHaveBeenCalled();
|
||||
expect(view.model.get('status')).not.toEqual(defaultStatus);
|
||||
expect(view.model.get('status')).toEqual(publishedStatus);
|
||||
expect(view.model.save).toHaveBeenCalled();
|
||||
|
||||
// Publish button should be removed once API has completed its call
|
||||
expect(view.$el.find('.js-publish-program').length).toEqual(1);
|
||||
view.model.trigger('sync');
|
||||
expect(view.$el.find('.js-publish-program').length).toEqual(0);
|
||||
});
|
||||
|
||||
it('should show a validation error when publish button pressed if validation fails', function() {
|
||||
var $input = view.$el.find('#program-marketing-slug');
|
||||
|
||||
view.model.set('marketing_slug', '');
|
||||
openPublishModal();
|
||||
expect(view.modalView).not.toBeDefined();
|
||||
expect($input).toHaveClass(errorClass);
|
||||
});
|
||||
|
||||
it('should destroy the publish modal when the cancel button is clicked', function() {
|
||||
openPublishModal();
|
||||
expect(view.modalView).toBeDefined();
|
||||
|
||||
// Close the modal
|
||||
view.$el.find('.js-cancel').click();
|
||||
|
||||
// Expect the modal DOM elements to not be there anymore
|
||||
expect(view.$el.find('.js-cancel').length).toEqual(0);
|
||||
expect(view.modalView.$parentEl.html().length).toEqual(0);
|
||||
});
|
||||
|
||||
it('should destroy the publish modal when the esc key is pressed', function() {
|
||||
openPublishModal();
|
||||
expect(view.modalView).toBeDefined();
|
||||
|
||||
// Close the modal
|
||||
keyPress(view.modalView.$el, constants.keyCodes.esc);
|
||||
|
||||
// Expect the modal DOM elements to not be there anymore
|
||||
expect(view.$el.find('.js-cancel').length).toEqual(0);
|
||||
expect(view.modalView.$parentEl.html().length).toEqual(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -16,4 +16,3 @@
|
||||
@import 'elements/footer';
|
||||
@import 'elements-v2/sock';
|
||||
@import 'elements-v2/tooltip';
|
||||
@import 'programs/build';
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
|
||||
// ------------------------------
|
||||
// Programs: App Container
|
||||
|
||||
// About: styling for setting up the wrapper.
|
||||
.program-app {
|
||||
&.layout-1q3q {
|
||||
max-width: 1250px;
|
||||
|
||||
// HtmlUtils.template wraps children of this in an anonymous div. We need to make sure they render at full width
|
||||
& > div {
|
||||
@include span(12);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
// ------------------------------
|
||||
// Programs: Main Style Compile
|
||||
|
||||
// About: Sass compile for the Programs IDA.
|
||||
|
||||
@import 'components';
|
||||
@import 'views';
|
||||
@import 'modals';
|
||||
@import 'app-container';
|
||||
@@ -1,99 +0,0 @@
|
||||
// ------------------------------
|
||||
// Programs: Components
|
||||
|
||||
// About: styling for specific UI components ranging from global to modular.
|
||||
|
||||
// #BUTTONS
|
||||
// #FORMS
|
||||
|
||||
|
||||
// ------------------------------
|
||||
// #BUTTONS
|
||||
// ------------------------------
|
||||
.btn {
|
||||
&.btn-delete,
|
||||
&.btn-edit {
|
||||
border: none;
|
||||
background: none;
|
||||
color: palette(grayscale, base);
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
color: $black;
|
||||
}
|
||||
}
|
||||
|
||||
&.full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&.right {
|
||||
@include float(right);
|
||||
}
|
||||
|
||||
&.btn-create {
|
||||
background: palette(success, base);
|
||||
border-color: palette(success, base);
|
||||
|
||||
// STATE: hover and focus
|
||||
&:hover,
|
||||
&.is-hovered,
|
||||
&:focus,
|
||||
&.is-focused {
|
||||
background: shade($success, 33%);
|
||||
color: palette(primary, accent);
|
||||
}
|
||||
|
||||
// STATE: is pressed or active
|
||||
&:active,
|
||||
&.is-pressed,
|
||||
&.is-active {
|
||||
border-color: shade($success, 33%);
|
||||
background: shade($success, 33%);
|
||||
}
|
||||
|
||||
.text {
|
||||
margin-left: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon,
|
||||
.text {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// #FORMS
|
||||
// ------------------------------
|
||||
.field {
|
||||
.invalid {
|
||||
border: 2px solid palette(error, base);
|
||||
}
|
||||
|
||||
.field-input,
|
||||
.field-hint,
|
||||
.field-message {
|
||||
min-with: 300px;
|
||||
width: 50%;
|
||||
|
||||
&.is-hidden {
|
||||
@extend .is-hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.copy {
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.form-group {
|
||||
&.bg-white {
|
||||
background-color: $white;
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
// ------------------------------
|
||||
// Programs: Modals
|
||||
|
||||
// About: styling for modals.
|
||||
.modal-window-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
background-color: palette(grayscale, dark);
|
||||
opacity: 0.5;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-window {
|
||||
position: absolute;
|
||||
background-color: $black;
|
||||
width: 80%;
|
||||
left: 10%;
|
||||
top: 40%;
|
||||
z-index: 1001;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
margin: 5px;
|
||||
padding: 20px;
|
||||
background-color: palette(grayscale, dark);
|
||||
border-top: 5px solid palette(warning, accent);
|
||||
|
||||
.copy {
|
||||
color: $white;
|
||||
}
|
||||
|
||||
.emphasized {
|
||||
color: $white;
|
||||
font-weight: font-weight(bold);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
padding: 10px 20px;
|
||||
|
||||
.btn {
|
||||
color: palette(grayscale, back);
|
||||
}
|
||||
|
||||
.btn-brand {
|
||||
background: palette(warning, back);
|
||||
color: palette(grayscale, dark);
|
||||
border-color: palette(warning, accent);
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
background: palette(warning, back);
|
||||
border-color: palette(warning, accent);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-neutral {
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
border-color: palette(grayscale, back)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@include breakpoint( $bp-screen-sm ) {
|
||||
.modal-window {
|
||||
width: 440px;
|
||||
left: calc( 50% - 220px );
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
|
||||
// ------------------------------
|
||||
// Programs: Views
|
||||
|
||||
// About: styling for specific views.
|
||||
|
||||
// ------------------------------
|
||||
// #PROGRAM LISTS
|
||||
// ------------------------------
|
||||
.program-list {
|
||||
list-style-type: none;
|
||||
padding-left: 0;
|
||||
|
||||
.program-details {
|
||||
.name {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.status {
|
||||
@include float(right);
|
||||
}
|
||||
|
||||
.category {
|
||||
color: palette(grayscale, base);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.app-header {
|
||||
@include clearfix();
|
||||
@include grid-row;
|
||||
border-bottom: 1px solid palette(grayscale, base);
|
||||
margin-bottom: 20px;
|
||||
|
||||
form {
|
||||
@include span(12);
|
||||
}
|
||||
}
|
||||
|
||||
.course-container {
|
||||
.subtitle {
|
||||
color: palette(grayscale, base);
|
||||
}
|
||||
}
|
||||
|
||||
.run-container {
|
||||
position: relative;
|
||||
margin: {
|
||||
bottom: 20px;
|
||||
};
|
||||
|
||||
&:before {
|
||||
content: '';
|
||||
width: 5px;
|
||||
height: calc( 100% + 1px );
|
||||
background: palette(grayscale, base);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.course-container {
|
||||
margin: {
|
||||
bottom: 20px;
|
||||
};
|
||||
}
|
||||
@@ -12,4 +12,3 @@ $pattern-library-path: '../edx-pattern-library' !default;
|
||||
|
||||
// Load the shared build
|
||||
@import 'build-v2';
|
||||
@import 'programs/build';
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
|
||||
}
|
||||
|
||||
.action-create-course, .action-create-library, .action-create-program {
|
||||
.action-create-course, .action-create-library {
|
||||
@extend %btn-primary-green;
|
||||
@extend %t-action3;
|
||||
}
|
||||
@@ -318,7 +318,7 @@
|
||||
}
|
||||
|
||||
// ELEM: course listings
|
||||
.courses-tab, .libraries-tab, .programs-tab {
|
||||
.courses-tab, .libraries-tab {
|
||||
display: none;
|
||||
|
||||
&.active {
|
||||
@@ -326,7 +326,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.courses, .libraries, .programs {
|
||||
.courses, .libraries {
|
||||
.title {
|
||||
@extend %t-title6;
|
||||
margin-bottom: $baseline;
|
||||
|
||||
@@ -38,11 +38,6 @@ from openedx.core.djangolib.markup import HTML, Text
|
||||
<a href="#" class="button new-button new-library-button"><span class="icon fa fa-plus icon-inline" aria-hidden="true"></span>
|
||||
${_("New Library")}</a>
|
||||
% endif
|
||||
|
||||
% if is_programs_enabled:
|
||||
<a href=${program_authoring_url + 'new'} class="button new-button new-program-button"><span class="icon fa fa-plus icon-inline" aria-hidden="true"></span>
|
||||
${_("New Program")}</a>
|
||||
% endif
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
@@ -295,17 +290,10 @@ from openedx.core.djangolib.markup import HTML, Text
|
||||
</div>
|
||||
%endif
|
||||
|
||||
% if libraries_enabled or is_programs_enabled:
|
||||
% if libraries_enabled:
|
||||
<ul id="course-index-tabs">
|
||||
<li class="courses-tab active"><a>${_("Courses")}</a></li>
|
||||
|
||||
% if libraries_enabled:
|
||||
<li class="libraries-tab"><a>${_("Libraries")}</a></li>
|
||||
% endif
|
||||
|
||||
% if is_programs_enabled:
|
||||
<li class="programs-tab"><a>${_("Programs")}</a></li>
|
||||
% endif
|
||||
<li class="libraries-tab"><a>${_("Libraries")}</a></li>
|
||||
</ul>
|
||||
% endif
|
||||
|
||||
@@ -516,52 +504,6 @@ from openedx.core.djangolib.markup import HTML, Text
|
||||
</div>
|
||||
%endif
|
||||
|
||||
% if is_programs_enabled:
|
||||
% if len(programs) > 0:
|
||||
<div class="programs programs-tab">
|
||||
<!-- Classes related to courses are intentionally reused here, to duplicate the styling used for course listing. -->
|
||||
<ul class="list-courses">
|
||||
% for program in programs:
|
||||
<li class="course-item">
|
||||
|
||||
<a class="program-link" href=${program_authoring_url + str(program['id'])}>
|
||||
<h3 class="course-title">${program['name']}</h3>
|
||||
|
||||
<div class="course-metadata">
|
||||
<span class="course-org metadata-item">
|
||||
<!-- As of this writing, programs can only be owned by one organization. If that constraint is relaxed, this will need to be revisited. -->
|
||||
<span class="label">${_("Organization:")}</span> <span class="value">${program['organizations'][0]['key']}</span>
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
</li>
|
||||
% endfor
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
% else:
|
||||
<div class="notice notice-incontext notice-instruction notice-instruction-nocourses list-notices programs-tab">
|
||||
<div class="notice-item has-actions">
|
||||
|
||||
<div class="msg">
|
||||
<h3 class="title">${_("You haven't created any programs yet.")}</h3>
|
||||
<div class="copy">
|
||||
<p>${_("Programs are groups of courses related to a common subject.")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="list-actions">
|
||||
<li class="action-item">
|
||||
<a href=${program_authoring_url + 'new'} class="action-primary action-create new-button action-create-program new-program-button"><span class="icon fa fa-plus icon-inline" aria-hidden="true"></span> ${_('Create Your First Program')}</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
% endif
|
||||
% endif
|
||||
|
||||
</article>
|
||||
<aside class="content-supplementary" role="complementary">
|
||||
<div class="bit">
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
<div class="wrapper wrapper-modal-window wrapper-modal-window-<%- name %>"
|
||||
aria-describedby="modal-window-description"
|
||||
aria-labelledby="modal-window-title"
|
||||
aria-hidden=""
|
||||
role="dialog">
|
||||
<div class="modal-window-overlay"></div>
|
||||
<div class="js-focus-first modal-window modal-medium modal-type-confirm" tabindex="-1" aria-labelledby="modal-window-title">
|
||||
<div class="<%- name %>-modal">
|
||||
<div class="modal-content">
|
||||
<span class="copy copy-lead emphasized"><%- title %></span>
|
||||
<p class="copy copy-base"><%- body %></p>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<h3 class="sr-only"><%- gettext('Actions') %></h3>
|
||||
<button class="js-confirm btn btn-brand btn-base">
|
||||
<span><%- cta.confirm %></span>
|
||||
</button>
|
||||
<button class="js-cancel btn btn-neutral btn-base">
|
||||
<span><%- cta.cancel %></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,49 +0,0 @@
|
||||
<div class="card course-container">
|
||||
<% if ( display_name ) { %>
|
||||
<span class="copy copy-large emphasized"><%- display_name %></span>
|
||||
<% if ( status === 'unpublished' ) { %>
|
||||
<button class="js-remove-course btn btn-delete right" data-tooltip="<%- gettext('Delete course') %>">
|
||||
<span class="icon fa fa-trash-o" aria-hidden="true"></span>
|
||||
<span class="sr-only"><%- interpolate(
|
||||
gettext('Remove %(name)s from the program'),
|
||||
{ name: display_name },
|
||||
true
|
||||
) %></span>
|
||||
</button>
|
||||
<% } %>
|
||||
<p class="copy copy-base subtitle"><%- organization.display_name %> / <%- key %>
|
||||
<div class="js-course-runs"></div>
|
||||
<% if ( courseRuns.length > -1 ) { %>
|
||||
<button class="js-add-course-run btn btn-neutral btn-base full">
|
||||
<span class="icon fa fa-plus" aria-hidden="true"></span>
|
||||
<span class="text"><%- gettext('Add another run') %></span>
|
||||
</button>
|
||||
<% } %>
|
||||
<% } else { %>
|
||||
<form class="form js-course-form">
|
||||
<fieldset class="form-group">
|
||||
<div class="field">
|
||||
<label class="field-label" for="course-key-<%- cid %>"><%- gettext('Course Code') %></label>
|
||||
<input id="course-key-<%- cid %>" class="field-input input-text course-key" name="key" aria-describedby="course-key-<%- cid %>-desc" maxlength="255" required>
|
||||
<div class="field-message">
|
||||
<span class="field-message-content"></span>
|
||||
</div>
|
||||
<div class="field-hint" id="course-key-<%- cid %>-desc">
|
||||
<p><%- gettext('The unique number that identifies your course within your organization, e.g. CS101.') %></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label" for="display-name-<%- cid %>"><%- gettext('Course Title') %></label>
|
||||
<input id="display-name-<%- cid %>" class="field-input input-text display-name" name="display_name" aria-describedby="display-name-<%- cid %>-desc" maxlength="255" required>
|
||||
<div class="field-message">
|
||||
<span class="field-message-content"></span>
|
||||
</div>
|
||||
<div class="field-hint" id="display-name-<%- cid %>-desc">
|
||||
<p><%- gettext('The title entered here will override the title set for the individual run of the course. It will be displayed on the XSeries progress page and in marketing presentations.') %></p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-primary js-select-course"><%- gettext('Save Course') %></button>
|
||||
</fieldset>
|
||||
</form>
|
||||
<% } %>
|
||||
</div>
|
||||
@@ -1,36 +0,0 @@
|
||||
<div class="card run-container">
|
||||
<% if ( !_.isUndefined(course_key) ) { %>
|
||||
<span class="copy copy-large emphasized"><%- interpolate(
|
||||
gettext('Run %(key)s'),
|
||||
{ key: course_key },
|
||||
true
|
||||
) %></span>
|
||||
<% if ( programStatus === 'unpublished' ) { %>
|
||||
<button class="js-remove-run btn btn-delete right" data-tooltip="<%- gettext('Delete course run') %>">
|
||||
<span class="icon fa fa-trash-o" aria-hidden="true"></span>
|
||||
<span class="sr-only"><%- interpolate(
|
||||
gettext('Remove run %(key)s from the program'),
|
||||
{ key: course_key },
|
||||
true
|
||||
) %></span>
|
||||
</button>
|
||||
<% } %>
|
||||
<div class="copy copy-base subtitle"><%- interpolate(
|
||||
gettext('Start Date: %(date)s'),
|
||||
{ date: start_date },
|
||||
true
|
||||
) %></div>
|
||||
<div class="copy copy-base subtitle"><%- interpolate(
|
||||
gettext('Mode: %(mode)s'),
|
||||
{ mode: mode_slug },
|
||||
true
|
||||
) %></div>
|
||||
<% } else { %>
|
||||
<select class="js-course-run-select">
|
||||
<option><%- gettext('Please select a Course Run') %></option>
|
||||
<% _.each(courseRuns, function(run) { %>
|
||||
<option value="<%- run.id %>"><%- run.name %>: <%- run.id %></option>
|
||||
<% }); %>
|
||||
</select>
|
||||
<% } %>
|
||||
</div>
|
||||
@@ -1,67 +0,0 @@
|
||||
<h3 class="hd-3 emphasized"><%- gettext('Create a New Program') %></h3>
|
||||
<form class="form">
|
||||
<fieldset class="form-group bg-white">
|
||||
<div class="field">
|
||||
<label class="field-label" for="program-type"><%- gettext('Program type') %></label>
|
||||
<select id="program-type" class="field-input input-select program-type" name="category">
|
||||
<option value=""><%- gettext('Select a type') %></option>
|
||||
<option value="XSeries"><%- gettext('XSeries') %></option>
|
||||
<option value="MicroMasters"><%- gettext('MicroMasters') %></option>
|
||||
</select>
|
||||
<div class="field-message">
|
||||
<span class="field-message-content"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field-label" for="program-org"><%- gettext('Organization') %></label>
|
||||
<select id="program-org" class="field-input input-select program-org" name="organizations">
|
||||
<option value="false"><%- gettext('Select an organization') %></option>
|
||||
<% _.each( orgs, function( org ) { %>
|
||||
<option value="<%- org.key %>"><%- org.display_name %></option>
|
||||
<% }); %>
|
||||
</select>
|
||||
<div class="field-message">
|
||||
<span class="field-message-content"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field-label" for="program-name"><%- gettext('Name') %></label>
|
||||
<input id="program-name" class="field-input input-text program-name" name="name" maxlength="64" aria-describedby="program-name-desc" required>
|
||||
<div class="field-message">
|
||||
<span class="field-message-content"></span>
|
||||
</div>
|
||||
<div class="field-hint" id="program-name-desc">
|
||||
<p><%- gettext('The public display name of the program.') %></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field-label" for="program-subtitle"><%- gettext('Subtitle') %></label>
|
||||
<input id="program-subtitle" class="field-input input-text program-subtitle" name="subtitle" maxlength="255" aria-describedby="program-subtitle-desc">
|
||||
<div class="field-message">
|
||||
<span class="field-message-content"></span>
|
||||
</div>
|
||||
<div class="field-hint" id="program-subtitle-desc">
|
||||
<p><%- gettext('A short description of the program, including concepts covered and expected outcomes (255 character limit).') %></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field-label" for="program-marketing-slug"><%- gettext('Marketing Slug') %></label>
|
||||
<input id="program-marketing-slug" class="field-input input-text program-marketing-slug" name="marketing_slug" maxlength="255" aria-describedby="program-marketing-slug-desc">
|
||||
<div class="field-message">
|
||||
<span class="field-message-content"></span>
|
||||
</div>
|
||||
<div class="field-hint" id="program-marketing-slug-desc">
|
||||
<p><%- gettext('Slug used to generate links to the marketing site.') %></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<button class="btn btn-brand btn-base js-create-program"><%- gettext('Create') %></button>
|
||||
<button class="btn btn-neutral btn-base js-abort-view"><%- gettext('Cancel') %></button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
@@ -1,63 +0,0 @@
|
||||
<header class="app-header">
|
||||
<form class="layout layout-1q3q">
|
||||
<div class="layout-col layout-col-b">
|
||||
<div class="js-inline-edit field">
|
||||
<span class="js-model-value copy copy-large emphasized"><%- name %></span>
|
||||
<label for="program-name" class="sr-only"><%- gettext('Name') %></label>
|
||||
<input type="text" value="<%- name %>" id="program-name" class="program-name field-input is-hidden" name="name" data-field="name" maxlength="64" required>
|
||||
<button class="js-enable-edit btn btn-edit" data-tooltip="<%- gettext('Edit the program title') %>">
|
||||
<span class="icon fa fa-pencil" aria-hidden="true"></span>
|
||||
<span class="sr-only"><%- gettext('Edit the program\'s name.') %></span>
|
||||
</button>
|
||||
<div class="field-message">
|
||||
<span class="field-message-content"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="js-inline-edit field">
|
||||
<span class="js-model-value copy copy-base subtitle"><%- subtitle %></span>
|
||||
<label for="program-subtitle" class="sr-only"><%- gettext('Subtitle') %></label>
|
||||
<input type="text" value="<%- subtitle %>" id="program-subtitle" class="program-subtitle field-input is-hidden" name="subtitle" data-field="subtitle" maxlength="255">
|
||||
<button class="js-enable-edit btn btn-edit" data-tooltip="<%- gettext('Edit the program subtitle') %>">
|
||||
<span class="icon fa fa-pencil" aria-hidden="true"></span>
|
||||
<span class="sr-only"><%- gettext('Edit the program\'s subtitle.') %></span>
|
||||
</button>
|
||||
<div class="field-message">
|
||||
<span class="field-message-content"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="js-inline-edit field">
|
||||
<span class="js-model-value copy copy-base subtitle"><%- marketing_slug %></span>
|
||||
<label for="program-subtitle" class="sr-only"><%- gettext('Marketing Slug') %></label>
|
||||
<input type="text" value="<%- marketing_slug %>" id="program-marketing-slug" class="program-marketing-slug field-input is-hidden" name="marketing_slug" data-field="marketing_slug" maxlength="255">
|
||||
<button class="js-enable-edit btn btn-edit" data-tooltip="<%- gettext('Edit the program marketing slug') %>">
|
||||
<span class="icon fa fa-pencil" aria-hidden="true"></span>
|
||||
<span class="sr-only"><%- gettext('Edit the program\'s marketing slug.') %></span>
|
||||
</button>
|
||||
<div class="field-message">
|
||||
<span class="field-message-content"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="layout-col layout-col-a">
|
||||
<% if ( status === 'unpublished' ) { %>
|
||||
<button class="js-publish-program btn btn-neutral btn-base btn-grey right">
|
||||
<span><%- gettext('Publish') %></span>
|
||||
</button>
|
||||
<% } %>
|
||||
</div>
|
||||
</form>
|
||||
</header>
|
||||
<div class="layout-col layout-col-b">
|
||||
<div class="js-course-list"></div>
|
||||
<% if ( status === 'unpublished' ) { %>
|
||||
<button class="js-add-course btn btn-neutral btn-base full">
|
||||
<span class="icon fa fa-plus" aria-hidden="true"></span>
|
||||
<span class="text"><%- gettext('Add a course') %></span>
|
||||
</button>
|
||||
<% } %>
|
||||
</div>
|
||||
<aside class="js-aside layout-col layout-col-a"></aside>
|
||||
<div class="js-publish-modal"></div>
|
||||
@@ -1,21 +0,0 @@
|
||||
<%page expression_filter="h"/>
|
||||
<%namespace name='static' file='static_content.html'/>
|
||||
<%!
|
||||
from django.utils.translation import ugettext as _
|
||||
from openedx.core.djangolib.js_utils import js_escaped_string
|
||||
%>
|
||||
|
||||
<%inherit file="base.html" />
|
||||
<%block name="title">${_("Program Administration")}</%block>
|
||||
|
||||
<%block name="header_extras">
|
||||
<%! main_css = "style-main-v2" %>
|
||||
</%block>
|
||||
|
||||
<%block name="requirejs">
|
||||
require(["js/programs/program_admin_app"], function () {});
|
||||
</%block>
|
||||
|
||||
<%block name="content">
|
||||
<div class="js-program-admin program-app layout-1q3q layout" data-home-url="${studio_home_url}" data-lms-base-url="${lms_base_url}" data-programs-api-url="${programs_api_url}" data-auth-url="${programs_token_url}" data-username="${request.user.username}"></div>
|
||||
</%block>
|
||||
@@ -180,11 +180,6 @@
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
% elif show_programs_header:
|
||||
<h2 class="info-course">
|
||||
<span class="course-org">${settings.PLATFORM_NAME}</span><span class="course-number">${_("Programs")}</span>
|
||||
<span class="course-title">${_("Program Administration")}</span>
|
||||
</h2>
|
||||
% endif
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ from django.conf.urls import patterns, include, url
|
||||
# There is a course creators admin table.
|
||||
from ratelimitbackend import admin
|
||||
|
||||
from cms.djangoapps.contentstore.views.program import ProgramAuthoringView, ProgramsIdTokenView
|
||||
from cms.djangoapps.contentstore.views.organization import OrganizationListView
|
||||
|
||||
admin.autodiscover()
|
||||
@@ -178,14 +177,6 @@ urlpatterns += patterns(
|
||||
url(r'^maintenance/', include('maintenance.urls', namespace='maintenance')),
|
||||
)
|
||||
|
||||
urlpatterns += (
|
||||
# These views use a configuration model to determine whether or not to
|
||||
# display the Programs authoring app. If disabled, a 404 is returned.
|
||||
url(r'^programs/id_token/$', ProgramsIdTokenView.as_view(), name='programs_id_token'),
|
||||
# Drops into the Programs authoring app, which handles its own routing.
|
||||
url(r'^program/', ProgramAuthoringView.as_view(), name='programs'),
|
||||
)
|
||||
|
||||
if settings.DEBUG:
|
||||
try:
|
||||
from .urls_dev import urlpatterns as dev_urlpatterns
|
||||
|
||||
@@ -276,57 +276,3 @@ class HomePage(DashboardPage):
|
||||
Home page for Studio when logged in.
|
||||
"""
|
||||
url = BASE_URL + "/home/"
|
||||
|
||||
|
||||
class DashboardPageWithPrograms(DashboardPage):
|
||||
"""
|
||||
Extends DashboardPage for bok choy testing programs-related behavior.
|
||||
"""
|
||||
|
||||
def is_programs_tab_present(self):
|
||||
"""
|
||||
Determine if the programs tab appears on the studio home page.
|
||||
"""
|
||||
return self.q(css='#course-index-tabs .programs-tab a').present
|
||||
|
||||
def _click_programs_tab(self):
|
||||
"""
|
||||
DRY helper.
|
||||
"""
|
||||
self.q(css='#course-index-tabs .programs-tab a').click()
|
||||
self.wait_for_element_visibility("div.programs-tab.active", "Switch to programs tab")
|
||||
|
||||
def is_new_program_button_present(self):
|
||||
"""
|
||||
Determine if the "new program" button is visible in the top "nav
|
||||
actions" section of the page.
|
||||
"""
|
||||
return self.q(css='.nav-actions a.new-program-button').present
|
||||
|
||||
def is_empty_list_create_button_present(self):
|
||||
"""
|
||||
Determine if the "create your first program" button is visible under
|
||||
the programs tab (when the program list result is empty).
|
||||
"""
|
||||
self._click_programs_tab()
|
||||
return self.q(css='div.programs-tab.active a.new-program-button').present
|
||||
|
||||
def get_program_list(self):
|
||||
"""
|
||||
Fetch the content of the program list under the programs tab (assuming
|
||||
it is nonempty).
|
||||
"""
|
||||
self._click_programs_tab()
|
||||
div2info = lambda element: (
|
||||
element.find_element_by_css_selector('.course-title').text, # name
|
||||
element.find_element_by_css_selector('.course-org .value').text, # org key
|
||||
)
|
||||
return self.q(css='div.programs-tab li.course-item').map(div2info).results
|
||||
|
||||
def click_new_program_button(self):
|
||||
"""
|
||||
Click on the new program button.
|
||||
"""
|
||||
self.q(css='.button.new-button.new-program-button').click()
|
||||
self.wait_for_ajax()
|
||||
self.wait_for_element_visibility(".account-username", "New program page is open")
|
||||
|
||||
@@ -7,7 +7,7 @@ from unittest import skip
|
||||
|
||||
from common.test.acceptance.fixtures.course import XBlockFixtureDesc
|
||||
from common.test.acceptance.tests.studio.base_studio_test import StudioCourseTest, ContainerBase
|
||||
from common.test.acceptance.pages.studio.index import DashboardPage, DashboardPageWithPrograms
|
||||
from common.test.acceptance.pages.studio.index import DashboardPage
|
||||
from common.test.acceptance.pages.studio.utils import click_studio_help, studio_help_links
|
||||
from common.test.acceptance.pages.studio.index import IndexPage, HomePage
|
||||
from common.test.acceptance.tests.studio.base_studio_test import StudioLibraryTest
|
||||
@@ -26,7 +26,6 @@ from common.test.acceptance.pages.studio.settings_advanced import AdvancedSettin
|
||||
from common.test.acceptance.pages.studio.settings_certificates import CertificatesPage
|
||||
from common.test.acceptance.pages.studio.import_export import ExportCoursePage, ImportCoursePage
|
||||
from common.test.acceptance.pages.studio.users import CourseTeamPage
|
||||
from common.test.acceptance.fixtures.programs import ProgramsConfigMixin
|
||||
from common.test.acceptance.tests.helpers import (
|
||||
AcceptanceTest,
|
||||
assert_nav_help_link,
|
||||
@@ -527,40 +526,6 @@ class LibraryExportHelpTest(StudioLibraryTest):
|
||||
)
|
||||
|
||||
|
||||
@attr(shard=10)
|
||||
class NewProgramHelpTest(ProgramsConfigMixin, AcceptanceTest):
|
||||
"""
|
||||
Test help links on a 'New Program' page
|
||||
"""
|
||||
def setUp(self):
|
||||
super(NewProgramHelpTest, self).setUp()
|
||||
self.auth_page = AutoAuthPage(self.browser, staff=True)
|
||||
self.program_page = DashboardPageWithPrograms(self.browser)
|
||||
self.auth_page.visit()
|
||||
self.set_programs_api_configuration(True)
|
||||
self.program_page.visit()
|
||||
|
||||
def test_program_create_nav_help(self):
|
||||
"""
|
||||
Scenario: Help link in navigation bar is working on 'New Program' page
|
||||
Given that I am on the 'New Program' page
|
||||
And I want help about the process
|
||||
And I click the 'Help' in the navigation bar
|
||||
Then Help link should open.
|
||||
And help url should end with 'index.html'
|
||||
"""
|
||||
self.program_page.click_new_program_button()
|
||||
href = 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course' \
|
||||
'/en/latest/index.html'
|
||||
|
||||
# Assert that help link is correct.
|
||||
assert_nav_help_link(
|
||||
test=self,
|
||||
page=self.program_page,
|
||||
href=href,
|
||||
)
|
||||
|
||||
|
||||
@attr(shard=10)
|
||||
class CourseOutlineHelpTest(StudioCourseTest):
|
||||
"""
|
||||
|
||||
@@ -5,18 +5,15 @@ from flaky import flaky
|
||||
from opaque_keys.edx.locator import LibraryLocator
|
||||
from uuid import uuid4
|
||||
|
||||
from common.test.acceptance.fixtures.catalog import CatalogFixture, CatalogConfigMixin
|
||||
from common.test.acceptance.fixtures.programs import ProgramsFixture, ProgramsConfigMixin
|
||||
from common.test.acceptance.pages.studio.auto_auth import AutoAuthPage
|
||||
from common.test.acceptance.pages.studio.library import LibraryEditPage
|
||||
from common.test.acceptance.pages.studio.index import DashboardPage, DashboardPageWithPrograms
|
||||
from common.test.acceptance.pages.studio.index import DashboardPage
|
||||
from common.test.acceptance.pages.lms.account_settings import AccountSettingsPage
|
||||
from common.test.acceptance.tests.helpers import (
|
||||
AcceptanceTest,
|
||||
select_option_by_text,
|
||||
get_selected_option_text
|
||||
)
|
||||
from openedx.core.djangoapps.programs.tests import factories
|
||||
|
||||
|
||||
class CreateLibraryTest(AcceptanceTest):
|
||||
@@ -68,98 +65,6 @@ class CreateLibraryTest(AcceptanceTest):
|
||||
self.assertTrue(self.dashboard_page.has_library(name=name, org=org, number=number))
|
||||
|
||||
|
||||
class DashboardProgramsTabTest(ProgramsConfigMixin, CatalogConfigMixin, AcceptanceTest):
|
||||
"""
|
||||
Test the programs tab on the studio home page.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(DashboardProgramsTabTest, self).setUp()
|
||||
self.stub_programs_api()
|
||||
self.stub_catalog_api()
|
||||
|
||||
self.auth_page = AutoAuthPage(self.browser, staff=True)
|
||||
self.dashboard_page = DashboardPageWithPrograms(self.browser)
|
||||
self.auth_page.visit()
|
||||
|
||||
def stub_programs_api(self):
|
||||
"""Stub out the programs API with fake data."""
|
||||
self.set_programs_api_configuration(is_enabled=True)
|
||||
ProgramsFixture().install_programs([])
|
||||
|
||||
def stub_catalog_api(self):
|
||||
"""Stub out the catalog API's program endpoint."""
|
||||
self.set_catalog_configuration(is_enabled=True)
|
||||
CatalogFixture().install_programs([])
|
||||
|
||||
def test_tab_is_disabled(self):
|
||||
"""
|
||||
The programs tab and "new program" button should not appear at all
|
||||
unless enabled via the config model.
|
||||
"""
|
||||
self.set_programs_api_configuration()
|
||||
self.dashboard_page.visit()
|
||||
self.assertFalse(self.dashboard_page.is_programs_tab_present())
|
||||
self.assertFalse(self.dashboard_page.is_new_program_button_present())
|
||||
|
||||
def test_tab_is_enabled_with_empty_list(self):
|
||||
"""
|
||||
The programs tab and "new program" button should appear when enabled
|
||||
via config. When the programs list is empty, a button should appear
|
||||
that allows creating a new program.
|
||||
"""
|
||||
self.dashboard_page.visit()
|
||||
self.assertTrue(self.dashboard_page.is_programs_tab_present())
|
||||
self.assertTrue(self.dashboard_page.is_new_program_button_present())
|
||||
results = self.dashboard_page.get_program_list()
|
||||
self.assertEqual(results, [])
|
||||
self.assertTrue(self.dashboard_page.is_empty_list_create_button_present())
|
||||
|
||||
def test_tab_is_enabled_with_nonempty_list(self):
|
||||
"""
|
||||
The programs tab and "new program" button should appear when enabled
|
||||
via config, and the results of the program list should display when
|
||||
the list is nonempty.
|
||||
"""
|
||||
test_program_values = [('first program', 'org1'), ('second program', 'org2')]
|
||||
|
||||
programs = [
|
||||
factories.Program(
|
||||
name=name,
|
||||
organizations=[
|
||||
factories.Organization(key=org),
|
||||
],
|
||||
course_codes=[
|
||||
factories.CourseCode(run_modes=[
|
||||
factories.RunMode(),
|
||||
]),
|
||||
]
|
||||
)
|
||||
for name, org in test_program_values
|
||||
]
|
||||
|
||||
ProgramsFixture().install_programs(programs)
|
||||
|
||||
self.dashboard_page.visit()
|
||||
|
||||
self.assertTrue(self.dashboard_page.is_programs_tab_present())
|
||||
self.assertTrue(self.dashboard_page.is_new_program_button_present())
|
||||
self.assertFalse(self.dashboard_page.is_empty_list_create_button_present())
|
||||
|
||||
results = self.dashboard_page.get_program_list()
|
||||
self.assertEqual(results, test_program_values)
|
||||
|
||||
def test_tab_requires_staff(self):
|
||||
"""
|
||||
The programs tab and "new program" button will not be available, even
|
||||
when enabled via config, if the user is not global staff.
|
||||
"""
|
||||
AutoAuthPage(self.browser, staff=False).visit()
|
||||
self.dashboard_page.visit()
|
||||
self.assertFalse(self.dashboard_page.is_programs_tab_present())
|
||||
self.assertFalse(self.dashboard_page.is_new_program_button_present())
|
||||
|
||||
|
||||
class StudioLanguageTest(AcceptanceTest):
|
||||
""" Test suite for the Studio Language """
|
||||
def setUp(self):
|
||||
|
||||
@@ -5,8 +5,10 @@ import mock
|
||||
from nose.plugins.attrib import attr
|
||||
|
||||
from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin
|
||||
from openedx.core.djangolib.testing.utils import skip_unless_lms
|
||||
|
||||
|
||||
@skip_unless_lms
|
||||
@attr(shard=2)
|
||||
@ddt.ddt
|
||||
# ConfigurationModels use the cache. Make every cache get a miss.
|
||||
|
||||
@@ -12,7 +12,7 @@ from provider.constants import CONFIDENTIAL
|
||||
from openedx.core.djangoapps.commerce.utils import ecommerce_api_client
|
||||
from openedx.core.djangoapps.programs.models import ProgramsApiConfig
|
||||
from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin
|
||||
from openedx.core.djangolib.testing.utils import CacheIsolationTestCase
|
||||
from openedx.core.djangolib.testing.utils import CacheIsolationTestCase, skip_unless_lms
|
||||
from openedx.core.lib.edx_api_utils import get_edx_api_data
|
||||
from student.tests.factories import UserFactory
|
||||
|
||||
@@ -22,6 +22,7 @@ TEST_API_URL = 'http://www-internal.example.com/api'
|
||||
TEST_API_SIGNING_KEY = 'edx'
|
||||
|
||||
|
||||
@skip_unless_lms
|
||||
@attr(shard=2)
|
||||
@httpretty.activate
|
||||
class TestGetEdxApiData(ProgramsApiConfigMixin, CacheIsolationTestCase):
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"dependencies": {
|
||||
"backbone": "~1.3.2",
|
||||
"backbone.paginator": "~2.0.3",
|
||||
"backbone-validation": "~0.11.5",
|
||||
"coffee-script": "1.6.1",
|
||||
"edx-pattern-library": "0.18.0",
|
||||
"edx-ui-toolkit": "1.5.1",
|
||||
|
||||
@@ -47,7 +47,6 @@ COMMON_LOOKUP_PATHS = [
|
||||
# A list of NPM installed libraries that should be copied into the common
|
||||
# static directory.
|
||||
NPM_INSTALLED_LIBRARIES = [
|
||||
'backbone-validation/dist/backbone-validation-min.js',
|
||||
'backbone/backbone.js',
|
||||
'backbone.paginator/lib/backbone.paginator.js',
|
||||
'moment-timezone/builds/moment-timezone-with-data.js',
|
||||
|
||||
Reference in New Issue
Block a user