feat!: Drop support for the legacy textbooks page. (#37545)

The legacy textbooks page has been replaced with an authoring MFE
equivalent.  We don't need to keep the old one around.

This work is part of https://github.com/openedx/edx-platform/issues/36108

BREAKING CHANGE: With this change the `legacy_studio.textbooks` waffle
flag will no longer be respected and the system will behave as if the
flag is always set to False.
This commit is contained in:
Feanil Patel
2025-11-20 17:56:11 -05:00
committed by GitHub
parent d082d3d87c
commit 5d0d60d426
29 changed files with 33 additions and 1752 deletions

View File

@@ -148,10 +148,15 @@ class CourseWaffleFlagsSerializer(serializers.Serializer):
def get_use_new_textbooks_page(self, obj):
"""
Method to get the use_new_textbooks_page switch
Method to indicate whether we should use_new_textbooks_page or not.
This used to be based on a waffle flag but the flag is being removed so we
default it to true for now until we can remove the need for it from the consumers
of this serializer and the related APIs.
See https://github.com/openedx/edx-platform/issues/37497
"""
course_key = self.get_course_key()
return toggles.use_new_textbooks_page(course_key)
return True
def get_use_new_group_configurations_page(self, obj):
"""

View File

@@ -1467,6 +1467,15 @@ class ContentStoreTest(ContentStoreTestCase):
)
self.assertEqual(resp.status_code, 200)
def test_get_json(handler):
# Helper function for getting HTML for a page in Studio and
# checking that it does not error.
resp = self.client.get(
get_url(handler, course_key, 'course_key_string'),
HTTP_ACCEPT="application/json",
)
self.assertEqual(resp.status_code, 200)
course_items = import_course_from_xml(
self.store, self.user.id, TEST_DATA_DIR, ['simple'], create_if_not_present=True
)
@@ -1499,8 +1508,7 @@ class ContentStoreTest(ContentStoreTestCase):
test_get_html('grading_handler')
with override_waffle_flag(toggles.LEGACY_STUDIO_ADVANCED_SETTINGS, True):
test_get_html('advanced_settings_handler')
with override_waffle_flag(toggles.LEGACY_STUDIO_TEXTBOOKS, True):
test_get_html('textbooks_list_handler')
test_get_json('textbooks_list_handler')
# go look at the Edit page
unit_key = course_key.make_usage_key('vertical', 'test_vertical')

View File

@@ -171,7 +171,6 @@ class CourseAdvanceSettingViewTest(CourseTestCase, MilestonesTestCaseMixin):
@override_waffle_flag(toggles.LEGACY_STUDIO_CUSTOM_PAGES, True)
@override_waffle_flag(toggles.LEGACY_STUDIO_SCHEDULE_DETAILS, True)
@override_waffle_flag(toggles.LEGACY_STUDIO_GRADING, True)
@override_waffle_flag(toggles.LEGACY_STUDIO_TEXTBOOKS, True)
def test_disable_advanced_settings_feature(self, disable_advanced_settings):
"""
If this feature is enabled, only Django Staff/Superuser should be able to access the "Advanced Settings" page.
@@ -190,7 +189,6 @@ class CourseAdvanceSettingViewTest(CourseTestCase, MilestonesTestCaseMixin):
'tabs_handler',
'settings_handler',
'grading_handler',
'textbooks_list_handler',
):
# Test that non-staff users don't see the "Advanced Settings" tab link.
response = self.non_staff_client.get_html(

View File

@@ -402,25 +402,6 @@ def use_new_certificates_page(course_key):
return not LEGACY_STUDIO_CERTIFICATES.is_enabled(course_key)
# .. toggle_name: legacy_studio.textbooks
# .. toggle_implementation: WaffleFlag
# .. toggle_default: False
# .. toggle_description: Temporarily fall back to the old Studio Textbooks page.
# .. toggle_use_cases: temporary
# .. toggle_creation_date: 2025-03-14
# .. toggle_target_removal_date: 2025-09-14
# .. toggle_tickets: https://github.com/openedx/edx-platform/issues/36275
# .. toggle_warning: In Ulmo, this toggle will be removed. Only the new (React-based) experience will be available.
LEGACY_STUDIO_TEXTBOOKS = CourseWaffleFlag('legacy_studio.textbooks', __name__)
def use_new_textbooks_page(course_key):
"""
Returns a boolean if new studio textbooks mfe is enabled
"""
return not LEGACY_STUDIO_TEXTBOOKS.is_enabled(course_key)
# .. toggle_name: legacy_studio.configurations
# .. toggle_implementation: WaffleFlag
# .. toggle_default: False

View File

@@ -50,7 +50,6 @@ from cms.djangoapps.contentstore.toggles import (
use_new_group_configurations_page,
use_new_import_page,
use_new_schedule_details_page,
use_new_textbooks_page,
use_new_unit_page,
use_new_updates_page,
use_new_video_uploads_page,
@@ -492,11 +491,10 @@ def get_textbooks_url(course_locator) -> str:
Gets course authoring microfrontend URL for textbooks page view.
"""
textbooks_url = None
if use_new_textbooks_page(course_locator):
mfe_base_url = get_course_authoring_url(course_locator)
course_mfe_url = f'{mfe_base_url}/course/{course_locator}/textbooks'
if mfe_base_url:
textbooks_url = course_mfe_url
mfe_base_url = get_course_authoring_url(course_locator)
course_mfe_url = f'{mfe_base_url}/course/{course_locator}/textbooks'
if mfe_base_url:
textbooks_url = course_mfe_url
return textbooks_url

View File

@@ -93,7 +93,6 @@ from ..toggles import (
use_new_updates_page,
use_new_advanced_settings_page,
use_new_grading_page,
use_new_textbooks_page,
use_new_group_configurations_page,
use_new_schedule_details_page
)
@@ -112,7 +111,6 @@ from ..utils import (
get_schedule_details_url,
get_studio_home_url,
get_updates_url,
get_textbooks_context,
get_textbooks_url,
initialize_permissions,
remove_all_instructors,
@@ -1457,17 +1455,18 @@ def textbooks_list_handler(request, course_key_string):
json: overwrite all textbooks in the course with the given list
"""
course_key = CourseKey.from_string(course_key_string)
if "application/json" not in request.META.get('HTTP_ACCEPT', 'text/html'):
# return HTML page
# We don't need to do an access check here because
# that is done when the endpoint for the actual content of the page.
# This is just to handle redirecting anyone that has bookmarked the old
# textbooks page.
return redirect(get_textbooks_url(course_key))
store = modulestore()
with store.bulk_operations(course_key):
course = get_course_and_check_access(course_key, request.user)
if "application/json" not in request.META.get('HTTP_ACCEPT', 'text/html'):
# return HTML page
if use_new_textbooks_page(course_key):
return redirect(get_textbooks_url(course_key))
textbooks_context = get_textbooks_context(course)
return render_to_response('textbooks.html', textbooks_context)
# from here on down, we know the client has requested JSON
if request.method == 'GET':
return JsonResponse(course.pdf_textbooks)

View File

@@ -4,9 +4,7 @@
import json
from unittest import TestCase
from edx_toggles.toggles.testutils import override_waffle_flag
from cms.djangoapps.contentstore import toggles
from cms.djangoapps.contentstore.tests.utils import CourseTestCase
from cms.djangoapps.contentstore.utils import reverse_course_url
@@ -20,15 +18,10 @@ class TextbookIndexTestCase(CourseTestCase):
super().setUp()
self.url = reverse_course_url('textbooks_list_handler', self.course.id)
@override_waffle_flag(toggles.LEGACY_STUDIO_TEXTBOOKS, True)
def test_view_index(self):
"Basic check that the textbook index page responds correctly"
resp = self.client.get(self.url)
self.assertEqual(resp.status_code, 200)
# we don't have resp.context right now,
# due to bugs in our testing harness :(
if resp.context and resp.context.get('course'):
self.assertEqual(resp.context['course'], self.course)
self.assertEqual(resp.status_code, 302)
def test_view_index_xhr(self):
"Check that we get a JSON response when requested via AJAX"

View File

@@ -234,11 +234,9 @@
'js/spec/models/section_spec',
'js/spec/models/settings_course_grader_spec',
'js/spec/models/settings_grading_spec',
'js/spec/models/textbook_spec',
'js/spec/models/upload_spec',
'js/spec/views/course_info_spec',
'js/spec/views/metadata_edit_spec',
'js/spec/views/textbook_spec',
'js/spec/views/upload_spec',
'js/spec/video/transcripts/message_manager_spec',
'js/spec/video/transcripts/utils_spec',

View File

@@ -1,14 +0,0 @@
define(['backbone', 'js/models/chapter'], function(Backbone, ChapterModel) {
var ChapterCollection = Backbone.Collection.extend({
model: ChapterModel,
comparator: 'order',
nextOrder: function() {
if (!this.length) { return 1; }
return this.last().get('order') + 1;
},
isEmpty: function() {
return this.length === 0 || this.every(function(m) { return m.isEmpty(); });
}
});
return ChapterCollection;
});

View File

@@ -1,8 +0,0 @@
define(['backbone', 'js/models/textbook'],
function(Backbone, TextbookModel) {
var TextbookCollection = Backbone.Collection.extend({
model: TextbookModel,
url: function() { return CMS.URL.TEXTBOOKS; }
});
return TextbookCollection;
});

View File

@@ -1,25 +0,0 @@
import * as gettext from 'gettext';
import * as Section from 'js/models/section';
import * as TextbookCollection from 'js/collections/textbook';
import * as ListTextbooksView from 'js/views/list_textbooks';
import './base';
// eslint-disable-next-line no-unused-expressions
'use strict';
export default function TextbooksFactory(textbooksJson) {
var textbooks = new TextbookCollection(textbooksJson, {parse: true}),
tbView = new ListTextbooksView({collection: textbooks});
$('.content-primary').append(tbView.render().el);
$('.nav-actions .new-button').click(function(event) {
tbView.addOne(event);
});
$(window).on('beforeunload', function() {
var dirty = textbooks.find(function(textbook) { return textbook.isDirty(); });
if (dirty) {
return gettext('You have unsaved changes. Do you really want to leave this page?');
}
});
}
export {TextbooksFactory};

View File

@@ -1,52 +0,0 @@
define(['backbone', 'gettext', 'backbone.associations'], function(Backbone, gettext) {
var Chapter = Backbone.AssociatedModel.extend({
defaults: function() {
return {
name: '',
asset_path: '',
order: this.collection ? this.collection.nextOrder() : 1
};
},
isEmpty: function() {
return !this.get('name') && !this.get('asset_path');
},
parse: function(response) {
if ('title' in response && !('name' in response)) {
response.name = response.title;
delete response.title;
}
if ('url' in response && !('asset_path' in response)) {
response.asset_path = response.url;
delete response.url;
}
return response;
},
toJSON: function() {
return {
title: this.get('name'),
url: this.get('asset_path')
};
},
// NOTE: validation functions should return non-internationalized error
// messages. The messages will be passed through gettext in the template.
validate: function(attrs, options) {
if (!attrs.name && !attrs.asset_path) {
return {
message: gettext('Chapter name and asset_path are both required'),
attributes: {name: true, asset_path: true}
};
} else if (!attrs.name) {
return {
message: gettext('Chapter name is required'),
attributes: {name: true}
};
} else if (!attrs.asset_path) {
return {
message: gettext('asset_path is required'),
attributes: {asset_path: true}
};
}
}
});
return Chapter;
});

View File

@@ -1,89 +0,0 @@
define(['backbone', 'underscore', 'gettext', 'js/models/chapter', 'js/collections/chapter',
'backbone.associations', 'cms/js/main'],
function(Backbone, _, gettext, ChapterModel, ChapterCollection) {
var Textbook = Backbone.AssociatedModel.extend({
defaults: function() {
return {
name: '',
chapters: new ChapterCollection([{}]),
showChapters: false,
editing: false
};
},
relations: [{
type: Backbone.Many,
key: 'chapters',
relatedModel: ChapterModel,
collectionType: ChapterCollection
}],
initialize: function() {
this.setOriginalAttributes();
return this;
},
setOriginalAttributes: function() {
this._originalAttributes = this.parse(this.toJSON());
},
reset: function() {
this.set(this._originalAttributes, {parse: true});
},
isDirty: function() {
return !_.isEqual(this._originalAttributes, this.parse(this.toJSON()));
},
isEmpty: function() {
return !this.get('name') && this.get('chapters').isEmpty();
},
urlRoot: function() { return CMS.URL.TEXTBOOKS; },
parse: function(response) {
var ret = $.extend(true, {}, response);
if ('tab_title' in ret && !('name' in ret)) {
ret.name = ret.tab_title;
delete ret.tab_title;
}
if ('url' in ret && !('chapters' in ret)) {
ret.chapters = {url: ret.url};
delete ret.url;
}
_.each(ret.chapters, function(chapter, i) {
chapter.order = chapter.order || i + 1;
});
return ret;
},
toJSON: function() {
return {
tab_title: this.get('name'),
chapters: this.get('chapters').toJSON()
};
},
// NOTE: validation functions should return non-internationalized error
// messages. The messages will be passed through gettext in the template.
validate: function(attrs, options) {
if (!attrs.name) {
return {
message: gettext('Textbook name is required'),
attributes: {name: true}
};
}
if (attrs.chapters.length === 0) {
return {
message: gettext('Please add at least one chapter'),
attributes: {chapters: true}
};
} else {
// validate all chapters
var invalidChapters = [];
attrs.chapters.each(function(chapter) {
if (!chapter.isValid()) {
invalidChapters.push(chapter);
}
});
if (!_.isEmpty(invalidChapters)) {
return {
message: gettext('All chapters must have a name and asset'),
attributes: {chapters: invalidChapters}
};
}
}
}
});
return Textbook;
});

View File

@@ -1,240 +0,0 @@
define(["backbone", "js/models/textbook", "js/collections/textbook", "js/models/chapter", "js/collections/chapter", "cms/js/main"],
function(Backbone, Textbook, TextbookSet, Chapter, ChapterSet, main) {
describe("Textbook model", function() {
beforeEach(function() {
main();
this.model = new Textbook();
CMS.URL.TEXTBOOKS = "/textbooks";
});
afterEach(() => delete CMS.URL.TEXTBOOKS);
describe("Basic", function() {
it("should have an empty name by default", function() {
expect(this.model.get("name")).toEqual("");
});
it("should not show chapters by default", function() {
expect(this.model.get("showChapters")).toBeFalsy();
});
it("should have a ChapterSet with one chapter by default", function() {
const chapters = this.model.get("chapters");
expect(chapters).toBeInstanceOf(ChapterSet);
expect(chapters.length).toEqual(1);
expect(chapters.at(0).isEmpty()).toBeTruthy();
});
it("should be empty by default", function() {
expect(this.model.isEmpty()).toBeTruthy();
});
it("should have a URL root", function() {
const urlRoot = _.result(this.model, 'urlRoot');
expect(urlRoot).toBeTruthy();
});
it("should be able to reset itself", function() {
this.model.set("name", "foobar");
this.model.reset();
expect(this.model.get("name")).toEqual("");
});
it("should not be dirty by default", function() {
expect(this.model.isDirty()).toBeFalsy();
});
it("should be dirty after it's been changed", function() {
this.model.set("name", "foobar");
expect(this.model.isDirty()).toBeTruthy();
});
it("should not be dirty after calling setOriginalAttributes", function() {
this.model.set("name", "foobar");
this.model.setOriginalAttributes();
expect(this.model.isDirty()).toBeFalsy();
});
});
describe("Input/Output", function() {
var deepAttributes = function(obj) {
if (obj instanceof Backbone.Model) {
return deepAttributes(obj.attributes);
} else if (obj instanceof Backbone.Collection) {
return obj.map(deepAttributes);
} else if (_.isArray(obj)) {
return _.map(obj, deepAttributes);
} else if (_.isObject(obj)) {
const attributes = {};
for (let prop of Object.keys(obj)) {
const val = obj[prop];
attributes[prop] = deepAttributes(val);
}
return attributes;
} else {
return obj;
}
};
it("should match server model to client model", function() {
const serverModelSpec = {
"tab_title": "My Textbook",
"chapters": [
{"title": "Chapter 1", "url": "/ch1.pdf"},
{"title": "Chapter 2", "url": "/ch2.pdf"},
]
};
const clientModelSpec = {
"name": "My Textbook",
"showChapters": false,
"editing": false,
"chapters": [{
"name": "Chapter 1",
"asset_path": "/ch1.pdf",
"order": 1
}, {
"name": "Chapter 2",
"asset_path": "/ch2.pdf",
"order": 2
}
]
};
const model = new Textbook(serverModelSpec, {parse: true});
expect(deepAttributes(model)).toEqual(clientModelSpec);
expect(model.toJSON()).toEqual(serverModelSpec);
});
});
describe("Validation", function() {
it("requires a name", function() {
const model = new Textbook({name: ""});
expect(model.isValid()).toBeFalsy();
});
it("requires at least one chapter", function() {
const model = new Textbook({name: "foo"});
model.get("chapters").reset();
expect(model.isValid()).toBeFalsy();
});
it("requires a valid chapter", function() {
const chapter = new Chapter();
chapter.isValid = () => false;
const model = new Textbook({name: "foo"});
model.get("chapters").reset([chapter]);
expect(model.isValid()).toBeFalsy();
});
it("requires all chapters to be valid", function() {
const chapter1 = new Chapter();
chapter1.isValid = () => true;
const chapter2 = new Chapter();
chapter2.isValid = () => false;
const model = new Textbook({name: "foo"});
model.get("chapters").reset([chapter1, chapter2]);
expect(model.isValid()).toBeFalsy();
});
it("can pass validation", function() {
const chapter = new Chapter();
chapter.isValid = () => true;
const model = new Textbook({name: "foo"});
model.get("chapters").reset([chapter]);
expect(model.isValid()).toBeTruthy();
});
});
});
describe("Textbook collection", function() {
beforeEach(function() {
CMS.URL.TEXTBOOKS = "/textbooks";
this.collection = new TextbookSet();
});
afterEach(() => delete CMS.URL.TEXTBOOKS);
it("should have a url set", function() {
const url = _.result(this.collection, 'url');
expect(url).toEqual("/textbooks");
});
});
describe("Chapter model", function() {
beforeEach(function() {
this.model = new Chapter();
});
describe("Basic", function() {
it("should have a name by default", function() {
expect(this.model.get("name")).toEqual("");
});
it("should have an asset_path by default", function() {
expect(this.model.get("asset_path")).toEqual("");
});
it("should have an order by default", function() {
expect(this.model.get("order")).toEqual(1);
});
it("should be empty by default", function() {
expect(this.model.isEmpty()).toBeTruthy();
});
});
describe("Validation", function() {
it("requires a name", function() {
const model = new Chapter({name: "", asset_path: "a.pdf"});
expect(model.isValid()).toBeFalsy();
});
it("requires an asset_path", function() {
const model = new Chapter({name: "a", asset_path: ""});
expect(model.isValid()).toBeFalsy();
});
it("can pass validation", function() {
const model = new Chapter({name: "a", asset_path: "a.pdf"});
expect(model.isValid()).toBeTruthy();
});
});
});
describe("Chapter collection", function() {
beforeEach(function() {
this.collection = new ChapterSet();
});
it("is empty by default", function() {
expect(this.collection.isEmpty()).toBeTruthy();
});
it("is empty if all chapters are empty", function() {
this.collection.add([{}, {}, {}]);
expect(this.collection.isEmpty()).toBeTruthy();
});
it("is not empty if a chapter is not empty", function() {
this.collection.add([{}, {name: "full"}, {}]);
expect(this.collection.isEmpty()).toBeFalsy();
});
it("should have a nextOrder function", function() {
expect(this.collection.nextOrder()).toEqual(1);
this.collection.add([{}]);
expect(this.collection.nextOrder()).toEqual(2);
this.collection.add([{}]);
expect(this.collection.nextOrder()).toEqual(3);
// verify that it doesn't just return an incrementing value each time
expect(this.collection.nextOrder()).toEqual(3);
// try going back one
this.collection.remove(this.collection.last());
expect(this.collection.nextOrder()).toEqual(2);
});
});
});

View File

@@ -1,401 +0,0 @@
define(["js/models/textbook", "js/models/chapter", "js/collections/chapter", "js/models/course",
"js/collections/textbook", "js/views/show_textbook", "js/views/edit_textbook", "js/views/list_textbooks",
"js/views/edit_chapter", "common/js/components/views/feedback_prompt",
"common/js/components/views/feedback_notification", "common/js/components/utils/view_utils",
"edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers",
"js/spec_helpers/modal_helpers"],
function(Textbook, Chapter, ChapterSet, Course, TextbookSet, ShowTextbook, EditTextbook, ListTextbooks, EditChapter,
Prompt, Notification, ViewUtils, AjaxHelpers, modal_helpers) {
describe("ShowTextbook", function() {
const tpl = readFixtures('show-textbook.underscore');
beforeEach(function() {
setFixtures($("<script>", {id: "show-textbook-tpl", type: "text/template"}).text(tpl));
appendSetFixtures(sandbox({id: "page-notification"}));
appendSetFixtures(sandbox({id: "page-prompt"}));
this.model = new Textbook({name: "Life Sciences", id: "0life-sciences"});
spyOn(this.model, "destroy").and.callThrough();
this.collection = new TextbookSet([this.model]);
this.view = new ShowTextbook({model: this.model});
this.promptSpies = jasmine.stealth.spyOnConstructor(Prompt, "Warning", ["show", "hide"]);
this.promptSpies.show.and.returnValue(this.promptSpies);
window.course = new Course({
id: "5",
name: "Course Name",
url_name: "course_name",
org: "course_org",
num: "course_num",
revision: "course_rev"
});
});
afterEach(() => {
delete window.course;
jasmine.stealth.clearSpies();
});
describe("Basic", function() {
it("should render properly", function() {
this.view.render();
expect(this.view.$el).toContainText("Life Sciences");
});
it("should set the 'editing' property on the model when the edit button is clicked", function() {
this.view.render().$(".edit").click();
expect(this.model.get("editing")).toBeTruthy();
});
it("should pop a delete confirmation when the delete button is clicked", function() {
this.view.render().$(".delete").click();
expect(this.promptSpies.constructor).toHaveBeenCalled();
const ctorOptions = this.promptSpies.constructor.calls.mostRecent().args[0];
expect(ctorOptions.title).toMatch(/Life Sciences/);
// hasn't actually been removed
expect(this.model.destroy).not.toHaveBeenCalled();
expect(this.collection).toContain(this.model);
});
it("should show chapters appropriately", function() {
this.model.get("chapters").add([{}, {}, {}]);
this.model.set('showChapters', false);
this.view.render().$(".show-chapters").click();
expect(this.model.get('showChapters')).toBeTruthy();
});
it("should hide chapters appropriately", function() {
this.model.get("chapters").add([{}, {}, {}]);
this.model.set('showChapters', true);
this.view.render().$(".hide-chapters").click();
expect(this.model.get('showChapters')).toBeFalsy();
});
});
describe("AJAX", function() {
beforeEach(function() {
this.savingSpies = jasmine.stealth.spyOnConstructor(Notification, "Mini",
["show", "hide"]);
this.savingSpies.show.and.returnValue(this.savingSpies);
CMS.URL.TEXTBOOKS = "/textbooks";
});
afterEach(() => delete CMS.URL.TEXTBOOKS);
it("should destroy itself on confirmation", function() {
const requests = AjaxHelpers["requests"](this);
this.view.render().$(".delete").click();
const ctorOptions = this.promptSpies.constructor.calls.mostRecent().args[0];
// run the primary function to indicate confirmation
ctorOptions.actions.primary.click(this.promptSpies);
// AJAX request has been sent, but not yet returned
expect(this.model.destroy).toHaveBeenCalled();
expect(requests.length).toEqual(1);
expect(this.savingSpies.constructor).toHaveBeenCalled();
expect(this.savingSpies.show).toHaveBeenCalled();
expect(this.savingSpies.hide).not.toHaveBeenCalled();
const savingOptions = this.savingSpies.constructor.calls.mostRecent().args[0];
expect(savingOptions.title).toMatch(/Deleting/);
// return a success response
requests[0].respond(204);
expect(this.savingSpies.hide).toHaveBeenCalled();
expect(this.collection.contains(this.model)).toBeFalsy();
});
});
});
describe("EditTextbook", () =>
describe("Basic", function() {
const tpl = readFixtures('edit-textbook.underscore');
beforeEach(function() {
setFixtures($("<script>", {id: "edit-textbook-tpl", type: "text/template"}).text(tpl));
appendSetFixtures(sandbox({id: "page-notification"}));
appendSetFixtures(sandbox({id: "page-prompt"}));
this.model = new Textbook({name: "Life Sciences", editing: true});
spyOn(this.model, 'save');
this.collection = new TextbookSet();
this.collection.add(this.model);
this.view = new EditTextbook({model: this.model});
spyOn(this.view, 'render').and.callThrough();
});
it("should render properly", function() {
this.view.render();
expect(this.view.$("input[name=textbook-name]").val()).toEqual("Life Sciences");
});
it("should allow you to create new empty chapters", function() {
this.view.render();
const numChapters = this.model.get("chapters").length;
this.view.$(".action-add-chapter").click();
expect(this.model.get("chapters").length).toEqual(numChapters+1);
expect(this.model.get("chapters").last().isEmpty()).toBeTruthy();
});
it("should save properly", function() {
this.view.render();
this.view.$("input[name=textbook-name]").val("starfish");
this.view.$("input[name=chapter1-name]").val("wallflower");
this.view.$("input[name=chapter1-asset-path]").val("foobar");
this.view.$("form").submit();
expect(this.model.get("name")).toEqual("starfish");
const chapter = this.model.get("chapters").first();
expect(chapter.get("name")).toEqual("wallflower");
expect(chapter.get("asset_path")).toEqual("foobar");
expect(this.model.save).toHaveBeenCalled();
});
it("should not save on invalid", function() {
this.view.render();
this.view.$("input[name=textbook-name]").val("");
this.view.$("input[name=chapter1-asset-path]").val("foobar.pdf");
this.view.$("form").submit();
expect(this.model.validationError).toBeTruthy();
expect(this.model.save).not.toHaveBeenCalled();
});
it("does not save on cancel", function() {
this.model.get("chapters").add([{name: "a", asset_path: "b"}]);
this.view.render();
this.view.$("input[name=textbook-name]").val("starfish");
this.view.$("input[name=chapter1-asset-path]").val("foobar.pdf");
this.view.$(".action-cancel").click();
expect(this.model.get("name")).not.toEqual("starfish");
const chapter = this.model.get("chapters").first();
expect(chapter.get("asset_path")).not.toEqual("foobar");
expect(this.model.save).not.toHaveBeenCalled();
});
it("does not re-render on cancel", function() {
this.view.render();
this.view.$(".action-cancel").click();
expect(this.view.render.calls.count()).toEqual(1);
});
it("should be possible to correct validation errors", function() {
this.view.render();
this.view.$("input[name=textbook-name]").val("");
this.view.$("input[name=chapter1-asset-path]").val("foobar.pdf");
this.view.$("form").submit();
expect(this.model.validationError).toBeTruthy();
expect(this.model.save).not.toHaveBeenCalled();
this.view.$("input[name=textbook-name]").val("starfish");
this.view.$("input[name=chapter1-name]").val("foobar");
this.view.$("form").submit();
expect(this.model.validationError).toBeFalsy();
expect(this.model.save).toHaveBeenCalled();
});
it("removes all empty chapters on cancel if the model has a non-empty chapter", function() {
const chapters = this.model.get("chapters");
chapters.at(0).set("name", "non-empty");
this.model.setOriginalAttributes();
this.view.render();
chapters.add([{}, {}, {}]); // add three empty chapters
expect(chapters.length).toEqual(4);
this.view.$(".action-cancel").click();
expect(chapters.length).toEqual(1);
expect(chapters.first().get('name')).toEqual("non-empty");
});
it("removes all empty chapters on cancel except one if the model has no non-empty chapters", function() {
const chapters = this.model.get("chapters");
this.view.render();
chapters.add([{}, {}, {}]); // add three empty chapters
expect(chapters.length).toEqual(4);
this.view.$(".action-cancel").click();
expect(chapters.length).toEqual(1);
});
})
);
describe("ListTextbooks", function() {
const noTextbooksTpl = readFixtures("no-textbooks.underscore");
const editTextbooktpl = readFixtures('edit-textbook.underscore');
beforeEach(function() {
appendSetFixtures($("<script>", {id: "no-textbooks-tpl", type: "text/template"}).text(noTextbooksTpl));
appendSetFixtures($("<script>", {id: "edit-textbook-tpl", type: "text/template"}).text(editTextbooktpl));
this.collection = new TextbookSet;
this.view = new ListTextbooks({collection: this.collection});
this.view.render();
});
it("should scroll to newly added textbook", function() {
spyOn(ViewUtils, 'setScrollOffset');
this.view.$(".new-button").click();
const $sectionEl = this.view.$el.find('section:last');
expect($sectionEl.length).toEqual(1);
expect(ViewUtils.setScrollOffset).toHaveBeenCalledWith($sectionEl, 0);
});
it("should focus first input element of newly added textbook", function() {
spyOn(jQuery.fn, 'focus').and.callThrough();
jasmine.addMatchers({
toHaveBeenCalledOnJQueryObject() {
return {
compare(actual, expected) {
return {
pass: actual.calls && actual.calls.mostRecent() &&
(actual.calls.mostRecent().object[0] === expected[0])
};
}
};
}});
this.view.$(".new-button").click();
const $inputEl = this.view.$el.find('section:last input:first');
expect($inputEl.length).toEqual(1);
// testing for element focused seems to be tricky
// (see http://stackoverflow.com/questions/967096)
// and the following doesn't seem to work
// expect($inputEl).toBeFocused()
// expect($inputEl.find(':focus').length).toEqual(1)
expect(jQuery.fn.focus).toHaveBeenCalledOnJQueryObject($inputEl);
});
it("should re-render when new textbook added", function() {
spyOn(this.view, 'render').and.callThrough();
this.view.$(".new-button").click();
expect(this.view.render.calls.count()).toEqual(1);
});
it("should remove textbook html section on model.destroy", function() {
this.model = new Textbook({name: "Life Sciences", id: "0life-sciences"});
this.collection.add(this.model);
this.view.render();
CMS.URL.TEXTBOOKS = "/textbooks"; // for AJAX
expect(this.view.$el.find('section').length).toEqual(1);
this.model.destroy();
expect(this.view.$el.find('section').length).toEqual(0);
delete CMS.URL.TEXTBOOKS;
});
});
// describe "ListTextbooks", ->
// noTextbooksTpl = readFixtures("no-textbooks.underscore")
//
// beforeEach ->
// setFixtures($("<script>", {id: "no-textbooks-tpl", type: "text/template"}).text(noTextbooksTpl))
// @showSpies = spyOnConstructor("ShowTextbook", ["render"])
// @showSpies.render.and.returnValue(@showSpies) # equivalent of `return this`
// showEl = $("<li>")
// @showSpies.$el = showEl
// @showSpies.el = showEl.get(0)
// @editSpies = spyOnConstructor("EditTextbook", ["render"])
// editEl = $("<li>")
// @editSpies.render.and.returnValue(@editSpies)
// @editSpies.$el = editEl
// @editSpies.el= editEl.get(0)
//
// @collection = new TextbookSet
// @view = new ListTextbooks({collection: @collection})
// @view.render()
//
// it "should render the empty template if there are no textbooks", ->
// expect(@view.$el).toContainText("You haven't added any textbooks to this course yet")
// expect(@view.$el).toContain(".new-button")
// expect(@showSpies.constructor).not.toHaveBeenCalled()
// expect(@editSpies.constructor).not.toHaveBeenCalled()
//
// it "should render ShowTextbook views by default if no textbook is being edited", ->
// # add three empty textbooks to the collection
// @collection.add([{}, {}, {}])
// # reset spies due to re-rendering on collection modification
// @showSpies.constructor.reset()
// @editSpies.constructor.reset()
// # render once and test
// @view.render()
//
// expect(@view.$el).not.toContainText(
// "You haven't added any textbooks to this course yet")
// expect(@showSpies.constructor).toHaveBeenCalled()
// expect(@showSpies.constructor.calls.length).toEqual(3);
// expect(@editSpies.constructor).not.toHaveBeenCalled()
//
// it "should render an EditTextbook view for a textbook being edited", ->
// # add three empty textbooks to the collection: the first and third
// # should be shown, and the second should be edited
// @collection.add([{editing: false}, {editing: true}, {editing: false}])
// editing = @collection.at(1)
// expect(editing.get("editing")).toBeTruthy()
// # reset spies
// @showSpies.constructor.reset()
// @editSpies.constructor.reset()
// # render once and test
// @view.render()
//
// expect(@showSpies.constructor).toHaveBeenCalled()
// expect(@showSpies.constructor.calls.length).toEqual(2)
// expect(@showSpies.constructor).not.toHaveBeenCalledWith({model: editing})
// expect(@editSpies.constructor).toHaveBeenCalled()
// expect(@editSpies.constructor.calls.length).toEqual(1)
// expect(@editSpies.constructor).toHaveBeenCalledWith({model: editing})
//
// it "should add a new textbook when the new-button is clicked", ->
// # reset spies
// @showSpies.constructor.reset()
// @editSpies.constructor.reset()
// # test
// @view.$(".new-button").click()
//
// expect(@collection.length).toEqual(1)
// expect(@view.$el).toContain(@editSpies.$el)
// expect(@view.$el).not.toContain(@showSpies.$el)
describe("EditChapter", function() {
beforeEach(function() {
modal_helpers.installModalTemplates();
this.model = new Chapter({
name: "Chapter 1",
asset_path: "/ch1.pdf"
});
this.collection = new ChapterSet();
this.collection.add(this.model);
this.view = new EditChapter({model: this.model});
spyOn(this.view, "remove").and.callThrough();
CMS.URL.UPLOAD_ASSET = "/upload";
window.course = new Course({name: "abcde"});
});
afterEach(function() {
delete CMS.URL.UPLOAD_ASSET;
delete window.course;
});
it("can render", function() {
this.view.render();
expect(this.view.$("input.chapter-name").val()).toEqual("Chapter 1");
expect(this.view.$("input.chapter-asset-path").val()).toEqual("/ch1.pdf");
});
it("can delete itself", function() {
this.view.render().$(".action-close").click();
expect(this.collection.length).toEqual(0);
expect(this.view.remove).toHaveBeenCalled();
});
// it "can open an upload dialog", ->
// uploadSpies = spyOnConstructor("UploadDialog", ["show", "el"])
// uploadSpies.show.and.returnValue(uploadSpies)
//
// @view.render().$(".action-upload").click()
// ctorOptions = uploadSpies.constructor.calls.mostRecent().args[0]
// expect(ctorOptions.model.get('title')).toMatch(/abcde/)
// expect(typeof ctorOptions.onSuccess).toBe('function')
// expect(uploadSpies.show).toHaveBeenCalled()
// Disabling because this test does not close the modal dialog. This can cause
// tests that run after it to fail (see STUD-1963).
xit("saves content when opening upload dialog", function() {
this.view.render();
this.view.$("input.chapter-name").val("rainbows");
this.view.$("input.chapter-asset-path").val("unicorns");
this.view.$(".action-upload").click();
expect(this.model.get("name")).toEqual("rainbows");
expect(this.model.get("asset_path")).toEqual("unicorns");
});
});
});

View File

@@ -1,6 +1,6 @@
define(["underscore", "sinon", "js/models/uploads", "js/views/uploads", "js/models/chapter",
define(["underscore", "sinon", "js/models/uploads", "js/views/uploads",
"edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers", "js/spec_helpers/modal_helpers"],
(_, sinon, FileUpload, UploadDialog, Chapter, AjaxHelpers, modal_helpers) =>
(_, sinon, FileUpload, UploadDialog, AjaxHelpers, modal_helpers) =>
describe("UploadDialog", function() {
const tpl = readFixtures("upload-dialog.underscore"),

View File

@@ -1,84 +0,0 @@
/* global course */
define(['underscore', 'jquery', 'gettext', 'edx-ui-toolkit/js/utils/html-utils',
'js/views/baseview', 'js/models/uploads', 'js/views/uploads', 'text!templates/edit-chapter.underscore'],
function(_, $, gettext, HtmlUtils, BaseView, FileUploadModel, UploadDialogView, editChapterTemplate) {
'use strict';
var EditChapter = BaseView.extend({
initialize: function() {
this.template = HtmlUtils.template(editChapterTemplate);
this.listenTo(this.model, 'change', this.render);
},
tagName: 'li',
className: function() {
return 'field-group chapter chapter' + this.model.get('order');
},
render: function() {
HtmlUtils.setHtml(
this.$el,
this.template({
name: this.model.get('name'),
asset_path: this.model.get('asset_path'),
order: this.model.get('order'),
error: this.model.validationError
})
);
return this;
},
events: {
'change .chapter-name': 'changeName',
'change .chapter-asset-path': 'changeAssetPath',
'click .action-close': 'removeChapter',
'click .action-upload': 'openUploadDialog',
submit: 'uploadAsset'
},
changeName: function(e) {
if (e && e.preventDefault) { e.preventDefault(); }
this.model.set({
name: this.$('.chapter-name').val()
}, {silent: true});
return this;
},
changeAssetPath: function(e) {
if (e && e.preventDefault) { e.preventDefault(); }
this.model.set({
asset_path: this.$('.chapter-asset-path').val()
}, {silent: true});
return this;
},
removeChapter: function(e) {
if (e && e.preventDefault) { e.preventDefault(); }
this.model.collection.remove(this.model);
return this.remove();
},
openUploadDialog: function(e) {
if (e && e.preventDefault) { e.preventDefault(); }
this.model.set({
name: this.$('input.chapter-name').val(),
asset_path: this.$('input.chapter-asset-path').val()
});
var msg = new FileUploadModel({
title: _.template(gettext('Upload a new PDF to “<%- name %>”'))(
{name: course.get('name')}),
message: gettext('Please select a PDF file to upload.'),
mimeTypes: ['application/pdf']
});
var that = this;
var view = new UploadDialogView({
model: msg,
onSuccess: function(response) {
var options = {};
if (!that.model.get('name')) {
options.name = response.asset.displayname;
}
options.asset_path = response.asset.portable_url;
that.model.set(options);
}
});
view.show();
}
});
return EditChapter;
});

View File

@@ -1,93 +0,0 @@
define(['js/views/baseview', 'underscore', 'jquery', 'js/views/edit_chapter', 'common/js/components/views/feedback_notification'],
function(BaseView, _, $, EditChapterView, NotificationView) {
var EditTextbook = BaseView.extend({
initialize: function() {
this.template = this.loadTemplate('edit-textbook');
this.listenTo(this.model, 'invalid', this.render);
var chapters = this.model.get('chapters');
this.listenTo(chapters, 'add', this.addOne);
this.listenTo(chapters, 'reset', this.addAll);
},
tagName: 'section',
className: 'textbook',
render: function() {
this.$el.html(this.template({ // xss-lint: disable=javascript-jquery-html
name: this.model.get('name'),
error: this.model.validationError
}));
this.addAll();
return this;
},
events: {
'change input[name=textbook-name]': 'setName',
submit: 'setAndClose',
'click .action-cancel': 'cancel',
'click .action-add-chapter': 'createChapter'
},
addOne: function(chapter) {
var view = new EditChapterView({model: chapter});
this.$('ol.chapters').append(view.render().el);
return this;
},
addAll: function() {
this.model.get('chapters').each(this.addOne, this);
},
createChapter: function(e) {
if (e && e.preventDefault) { e.preventDefault(); }
this.setValues();
this.model.get('chapters').add([{}]);
},
setName: function(e) {
if (e && e.preventDefault) { e.preventDefault(); }
this.model.set('name', this.$('#textbook-name-input').val(), {silent: true});
},
setValues: function() {
this.setName();
var that = this;
_.each(this.$('li'), function(li, i) {
var chapter = that.model.get('chapters').at(i);
if (!chapter) { return; }
chapter.set({
name: $('.chapter-name', li).val(),
asset_path: $('.chapter-asset-path', li).val()
});
});
return this;
},
setAndClose: function(e) {
if (e && e.preventDefault) { e.preventDefault(); }
this.setValues();
if (!this.model.isValid()) { return; }
var saving = new NotificationView.Mini({
title: gettext('Saving')
}).show();
var that = this;
this.model.save({}, {
success: function() {
that.model.setOriginalAttributes();
that.close();
},
complete: function() {
saving.hide();
}
});
},
cancel: function(e) {
if (e && e.preventDefault) { e.preventDefault(); }
this.model.reset();
return this.close();
},
close: function() {
var textbooks = this.model.collection;
this.remove();
if (this.model.isNew()) {
// if the textbook has never been saved, remove it
textbooks.remove(this.model);
}
// don't forget to tell the model that it's no longer being edited
this.model.set('editing', false);
return this;
}
});
return EditTextbook;
});

View File

@@ -1,52 +0,0 @@
define(['js/views/baseview', 'jquery', 'js/views/edit_textbook', 'js/views/show_textbook', 'common/js/components/utils/view_utils'],
function(BaseView, $, EditTextbookView, ShowTextbookView, ViewUtils) {
var ListTextbooks = BaseView.extend({
initialize: function() {
this.emptyTemplate = this.loadTemplate('no-textbooks');
this.listenTo(this.collection, 'change:editing', this.render);
this.listenTo(this.collection, 'destroy', this.handleDestroy);
},
tagName: 'div',
className: 'textbooks-list',
render: function() {
var textbooks = this.collection;
if (textbooks.length === 0) {
this.$el.html(this.emptyTemplate()); // xss-lint: disable=javascript-jquery-html
} else {
this.$el.empty();
var that = this;
textbooks.each(function(textbook) {
var view;
if (textbook.get('editing')) {
view = new EditTextbookView({model: textbook});
} else {
view = new ShowTextbookView({model: textbook});
}
that.$el.append(view.render().el);
});
}
return this;
},
events: {
'click .new-button': 'addOne'
},
addOne: function(e) {
var $sectionEl, $inputEl;
if (e && e.preventDefault) { e.preventDefault(); }
this.collection.add([{editing: true}]); // (render() call triggered here)
this.render();
// find the outer 'section' tag for the newly added textbook
$sectionEl = this.$el.find('section:last');
// scroll to put this at top of viewport
ViewUtils.setScrollOffset($sectionEl, 0);
// find the first input element in this section
$inputEl = $sectionEl.find('input:first');
// activate the text box (so user can go ahead and start typing straight away)
$inputEl.focus().select();
},
handleDestroy: function(model, collection, options) {
collection.remove(model);
}
});
return ListTextbooks;
});

View File

@@ -1,71 +0,0 @@
define(['js/views/baseview', 'underscore', 'gettext', 'common/js/components/views/feedback_notification',
'common/js/components/views/feedback_prompt'],
function(BaseView, _, gettext, NotificationView, PromptView) {
var ShowTextbook = BaseView.extend({
initialize: function() {
this.template = _.template($('#show-textbook-tpl').text());
this.listenTo(this.model, 'change', this.render);
this.listenTo(this.model, 'destroy', this.remove);
},
tagName: 'section',
className: 'textbook',
events: {
'click .edit': 'editTextbook',
'click .delete': 'confirmDelete',
'click .show-chapters': 'showChapters',
'click .hide-chapters': 'hideChapters'
},
render: function() {
var attrs = $.extend({}, this.model.attributes);
attrs.bookindex = this.model.collection.indexOf(this.model);
attrs.course = window.course.attributes;
this.$el.html(this.template(attrs)); // xss-lint: disable=javascript-jquery-html
return this;
},
editTextbook: function(e) {
if (e && e.preventDefault) { e.preventDefault(); }
this.model.set('editing', true);
},
confirmDelete: function(e) {
if (e && e.preventDefault) { e.preventDefault(); }
var textbook = this.model;
new PromptView.Warning({
title: _.template(gettext('Delete “<%- name %>”?'))(
{name: textbook.get('name')}
),
message: gettext("Deleting a textbook cannot be undone and once deleted any reference to it in your courseware's navigation will also be removed."),
actions: {
primary: {
text: gettext('Delete'),
click: function(view) {
view.hide();
var delmsg = new NotificationView.Mini({
title: gettext('Deleting')
}).show();
textbook.destroy({
complete: function() {
delmsg.hide();
}
});
}
},
secondary: {
text: gettext('Cancel'),
click: function(view) {
view.hide();
}
}
}
}).show();
},
showChapters: function(e) {
if (e && e.preventDefault) { e.preventDefault(); }
this.model.set('showChapters', true);
},
hideChapters: function(e) {
if (e && e.preventDefault) { e.preventDefault(); }
this.model.set('showChapters', false);
}
});
return ShowTextbook;
});

View File

@@ -27,7 +27,6 @@ var options = {
// {pattern: 'js/factories/context_course.js', webpack: true},
// {pattern: 'js/factories/edit_tabs.js', webpack: true},
// {pattern: 'js/factories/library.js', webpack: true},
// {pattern: 'js/factories/textbooks.js', webpack: true},
// ],
// All spec files should be imported in main_webpack.js, rather than being listed here

View File

@@ -72,7 +72,6 @@
@import 'views/static-pages';
@import 'views/container';
@import 'views/users';
@import 'views/textbooks';
@import 'views/export-git';
@import 'views/group-configuration';
@import 'views/video-upload';

View File

@@ -1,382 +0,0 @@
// studio - views - textbooks
// ====================
.view-textbooks {
.content-primary,
.content-supplementary {
box-sizing: border-box;
}
.content-primary {
@extend .ui-col-wide;
.no-textbook-content {
@extend %no-content;
color: $gray-d1;
}
.textbook {
@extend %ui-window;
position: relative;
.view-textbook {
padding: $baseline ($baseline*1.5);
header {
margin-bottom: 0;
border-bottom: 0;
}
.textbook-title {
@extend %t-title4;
@extend %t-strong;
@include margin-right($baseline*14);
}
.ui-toggle-expansion {
@include transition(rotate 0.15s ease-in-out 0.25s);
@extend %t-action1;
display: inline-block;
width: ($baseline*0.75);
vertical-align: text-bottom;
}
&.is-selectable {
@extend %ui-fake-link;
&:hover {
color: theme-color("primary");
.ui-toggle-expansion {
color: theme-color("primary");
}
}
}
.chapters {
margin-left: $baseline;
.chapter {
@extend %t-copy-sub2;
margin-bottom: ($baseline/4);
border-bottom: 1px solid $gray-l4;
.chapter-name {
display: inline-block;
vertical-align: middle;
width: 45%;
margin-right: ($baseline/2);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.chapter-asset-path {
display: inline-block;
width: 50%;
color: $gray-l1;
}
}
}
.actions {
@include transition(opacity 0.15s 0.25s ease-in-out);
opacity: 0;
position: absolute;
top: $baseline;
@include right($baseline);
.action {
display: inline-block;
margin-right: ($baseline/4);
.view {
@include blue-button;
@extend %t-action4;
}
.edit {
@include blue-button;
@extend %t-action4;
}
.delete {
@extend %ui-btn-non;
}
}
}
}
&:hover .actions {
opacity: 1;
}
.edit-textbook {
box-sizing: border-box;
border-radius: 2px;
width: 100%;
background: $white;
.wrapper-form {
padding: $baseline ($baseline*1.5);
}
fieldset {
margin-bottom: $baseline;
}
.actions {
box-shadow: inset 0 1px 2px $shadow;
border-top: 1px solid $gray-l1;
padding: ($baseline*0.75) $baseline;
background: $gray-l6;
.action {
margin-right: ($baseline/4);
&:last-child {
margin-right: 0;
}
}
// add a chapter is below with chapters styling
.action-primary {
@include blue-button;
@include transition(all 0.15s);
@extend %t-action2;
@extend %t-strong;
display: inline-block;
padding: ($baseline/5) $baseline;
text-transform: uppercase;
}
.action-secondary {
@include grey-button;
@include transition(all 0.15s);
@extend %t-action2;
@extend %t-strong;
display: inline-block;
padding: ($baseline/5) $baseline;
text-transform: uppercase;
}
}
.copy {
@extend %t-copy-sub2;
margin: ($baseline) 0 ($baseline/2) 0;
color: $gray;
strong {
@extend %t-strong;
}
}
.chapters-fields,
.textbook-fields {
@extend %cont-no-list;
.field {
margin: 0 0 ($baseline*0.75) 0;
&:last-child {
margin-bottom: 0;
}
&.required {
label {
@extend %t-strong;
}
label::after {
margin-left: ($baseline/4);
content: "*";
}
}
label,
input,
textarea {
display: block;
}
label {
@extend %t-copy-sub1;
@include transition(color, 0.15s, ease-in-out);
margin: 0 0 ($baseline/4) 0;
&.is-focused {
color: theme-color("primary");
}
}
&.add-textbook-name label {
@extend %t-title5;
}
//this section is borrowed from _account.scss - we should clean up and unify later
input,
textarea {
@extend %t-copy-base;
height: 100%;
width: 100%;
padding: ($baseline/2);
&.long {
width: 100%;
}
&.short {
width: 25%;
}
::-webkit-input-placeholder {
color: $gray-l4;
}
:-moz-placeholder {
color: $gray-l3;
}
::-moz-placeholder {
color: $gray-l3;
}
:-ms-input-placeholder {
color: $gray-l3;
}
&:focus {
+ .tip {
color: $gray-d1;
}
}
}
textarea.long {
height: ($baseline*5);
}
input[type="checkbox"] {
display: inline-block;
margin-right: ($baseline/4);
width: auto;
height: auto;
& + label {
display: inline-block;
}
}
.tip {
@extend %t-copy-sub2;
@include transition(color, 0.15s, ease-in-out);
display: block;
margin-top: ($baseline/4);
color: $gray-d1;
}
&.error {
label {
color: $red;
}
input {
border-color: $red;
}
}
}
.field-group {
@include clearfix();
margin: 0 0 ($baseline/2) 0;
.field {
display: block;
width: 46%;
border-bottom: none;
@include margin(0, ($baseline*0.75), 0, 0);
padding: ($baseline/4) 0 0 0;
@include float(left);
position: relative;
input,
textarea {
width: 100%;
}
.action-upload {
@extend %ui-btn-flat-outline;
position: absolute;
top: 3px;
@include right(0);
}
}
.action-close {
@include transition(color $tmg-f2 ease-in-out);
@extend %t-action1;
display: inline-block;
float: right;
margin-top: ($baseline*2);
border: 0;
padding: 0;
background: transparent;
color: $blue-l3;
&:hover {
color: $blue;
}
}
}
}
.action-add-chapter {
@extend %ui-btn-flat-outline;
@extend %t-action1;
display: block;
width: 100%;
margin: ($baseline*1.5) 0 0 0;
padding: ($baseline/2);
}
}
}
}
.content-supplementary {
@extend .ui-col-narrow;
}
}

View File

@@ -1,28 +0,0 @@
<form class="edit-textbook" id="edit_textbook_form">
<div class="wrapper-form">
<% if (error && error.message) { %>
<div id="edit_textbook_error" class="message message-status message-status error is-shown" name="edit_textbook_error">
<%- gettext(error.message) %>
</div>
<% } %>
<fieldset class="textbook-fields">
<legend class="sr"><%- gettext("Textbook information") %></legend>
<div class="input-wrap field text required add-textbook-name <% if(error && error.attributes && error.attributes.name) { print('error'); } %>">
<label for="textbook-name-input"><%- gettext("Textbook Name") %></label>
<input id="textbook-name-input" name="textbook-name" type="text" placeholder="<%- gettext("Introduction to Cookie Baking") %>" value="<%- name %>">
<span class="tip tip-stacked"><%- gettext("provide the title/name of the text book as you would like your students to see it") %></span>
</div>
</fieldset>
<fieldset class="chapters-fields">
<legend class="sr"><%- gettext("Chapter information") %></legend>
<ol class="chapters list-input enum"></ol>
<button class="action action-add-chapter"><span class="icon fa fa-plus" aria-hidden="true"></span> <%- gettext("Add a Chapter") %></button>
</fieldset>
</div>
<div class="actions">
<button class="action action-primary" type="submit"><%- gettext("Save") %></button>
<button class="action action-secondary action-cancel"><%- gettext("Cancel") %></button>
</div>
</form>

View File

@@ -1,3 +0,0 @@
<div class="no-textbook-content">
<p><%- gettext("You haven't added any textbooks to this course yet.") %><a href="#" class="button new-button"><span class="icon fa fa-plus" aria-hidden="true"></span><%- gettext("Add your first textbook") %></a></p>
</div>

View File

@@ -1,49 +0,0 @@
<div class="view-textbook">
<div class="wrap-textbook">
<header>
<h3 class="textbook-title"><%- name %></h3>
</header>
<% if(chapters.length > 1) {%>
<p><a href="#" class="chapter-toggle
<% if(showChapters){ print('hide'); } else { print('show'); } %>-chapters">
<span class="ui-toggle-expansion icon fa fa-caret-<% if(showChapters){ print('down'); } else { print('right'); } %>" aria-hidden="true"></span>
<%- chapters.length %> <%- gettext('PDF Chapters') %>
</a></p>
<% } else if(chapters.length === 1) { %>
<p dir="ltr">
<%- chapters.at(0).get("asset_path") %>
</p>
<% } %>
<% if(showChapters) { %>
<ol class="chapters">
<% chapters.each(function(chapter) { %>
<li class="chapter">
<span class="chapter-name"><%- chapter.get('name') %></span>
<span class="chapter-asset-path"><%- chapter.get('asset_path') %></span>
</li>
<% }) %>
</ol>
<% } %>
</div>
<ul class="actions textbook-actions">
<li class="action action-view">
<a href="//<%- CMS.URL.LMS_BASE %>/courses/<%- course.id %>/pdfbook/<%- bookindex %>/" class="view"><%- gettext("View Live") %></a>
</li>
<li class="action action-edit">
<button class="edit"><%- gettext("Edit") %></button>
</li>
<li class="action action-delete">
<button class="delete action-icon" title="<%- gettext('Delete') %>"><span class="icon fa fa-trash-o" aria-hidden="true" ></span></button>
</li>
</ul>
</div>

View File

@@ -1,104 +0,0 @@
<%page expression_filter="h"/>
<%inherit file="base.html" />
<%def name="online_help_token()"><% return "textbooks" %></%def>
<%namespace name='static' file='static_content.html'/>
<%!
from django.utils.translation import gettext as _
from openedx.core.djangolib.js_utils import dump_js_escaped_json, js_escaped_string
from cms.djangoapps.contentstore.utils import get_pages_and_resources_url
%>
<%block name="title">${_("Textbooks")}</%block>
<%block name="bodyclass">is-signedin course view-textbooks</%block>
<%block name="header_extras">
% for template_name in ["edit-textbook", "show-textbook", "no-textbooks", "basic-modal", "modal-button", "upload-dialog"]:
<script type="text/template" id="${template_name}-tpl">
<%static:include path="js/${template_name}.underscore" />
</script>
% endfor
</%block>
<%block name="jsextra">
<script type="text/javascript">
window.CMS = window.CMS || {};
CMS.URL = CMS.URL || {};
CMS.URL.UPLOAD_ASSET = "${upload_asset_url | n, js_escaped_string}"
CMS.URL.TEXTBOOKS = "${textbook_url | n, js_escaped_string}"
CMS.URL.LMS_BASE = "${settings.LMS_BASE | n, js_escaped_string}"
</script>
</%block>
<%block name="page_bundle">
<%static:webpack entry="js/factories/textbooks">
TextbooksFactory(${textbooks | n, dump_js_escaped_json});
</%static:webpack>
</%block>
<%block name="content">
<div class="wrapper-mast wrapper">
% if context_course:
<%
pages_and_resources_mfe_url = get_pages_and_resources_url(context_course.id)
pages_and_resources_mfe_enabled = bool(pages_and_resources_mfe_url)
%>
% endif
% if pages_and_resources_mfe_enabled:
<header class="mast has-actions">
<div class="jump-nav">
<nav class="nav-dd title ui-left">
<ol>
<li class="nav-item">
<span class="label">${_("Content")}</span>
<span class="spacer"> &rsaquo;</span>
</li>
<li class="nav-item">
<a class="hyperlink" href="${pages_and_resources_mfe_url}" rel="external">${_("Pages & Resources")}</a>
<span class="spacer"> &rsaquo;</span>
</li>
</ol>
</nav>
</div>
<h1 class="page-header">
<span class="sr">&gt; </span>${_("Textbooks")}
</h1>
% else:
<header class="mast has-actions has-subtitle">
<h1 class="page-header">
<small class="subtitle">${_("Content")}</small>
<span class="sr">&gt; </span>${_("Textbooks")}
</h1>
% endif
<nav class="nav-actions" aria-label="${_('Page Actions')}">
<h3 class="sr">${_("Page Actions")}</h3>
<ul>
<li class="nav-item">
<a href="#" class="button new-button"><span class="icon fa fa-plus" aria-hidden="true"></span> ${_("New Textbook")}</a>
</li>
</ul>
</nav>
</header>
</div>
<div class="wrapper-content wrapper">
<section class="content">
<article class="content-primary" role="main"></article>
<aside class="content-supplementary" role="complementary">
<div class="bit">
<h3 class="title-3">${_("Why should I break my textbook into chapters?")}</h3>
<p>${_("Breaking your textbook into multiple chapters reduces loading times for students, especially those with slow Internet connections. Breaking up textbooks into chapters can also help students more easily find topic-based information.")}</p>
</div>
<div class="bit">
<h3 class="title-3">${_("What if my book isn't divided into chapters?")}</h3>
<p>${_("If your textbook doesn't have individual chapters, you can upload the entire text as a single chapter and enter a name of your choice in the Chapter Name field.")}</p>
</div>
<div class="bit external-help">
<a href="${get_online_help_info(online_help_token())['doc_url']}" rel="noopener" target="_blank" class="button external-help-button">${_("Learn more about textbooks")}</a>
</div>
</aside>
</section>
</div>
</%block>

View File

@@ -27,7 +27,6 @@ module.exports = {
path.resolve(__dirname, '../cms/static/js/views/active_video_upload_list.js'),
path.resolve(__dirname, '../cms/static/js/views/assets.js'),
path.resolve(__dirname, '../cms/static/js/views/course_video_settings.js'),
path.resolve(__dirname, '../cms/static/js/views/edit_chapter.js'),
path.resolve(__dirname, '../cms/static/js/views/experiment_group_edit.js'),
path.resolve(__dirname, '../cms/static/js/views/license.js'),
path.resolve(__dirname, '../cms/static/js/views/modals/move_xblock_modal.js'),

View File

@@ -100,7 +100,6 @@ module.exports = Merge.merge({
// Studio
Import: './cms/static/js/features/import/factories/import.js',
CourseOrLibraryListing: './cms/static/js/features_jsx/studio/CourseOrLibraryListing.jsx',
'js/factories/textbooks': './cms/static/js/factories/textbooks.js',
'js/factories/container': './cms/static/js/factories/container.js',
'js/factories/context_course': './cms/static/js/factories/context_course.js',
'js/factories/library': './cms/static/js/factories/library.js',