Commit 5e81eb5b by Christina Roberts Committed by GitHub

Merge pull request #15390 from edx/christina/first-react

Convert Studio course and library dashboard lists to React.
parents 679bd2c6 e1e57b5d
...@@ -11,6 +11,7 @@ ...@@ -11,6 +11,7 @@
}, },
"modules": false "modules": false
} }
] ],
"babel-preset-react"
] ]
} }
...@@ -24,7 +24,6 @@ from opaque_keys.edx.keys import CourseKey, UsageKey ...@@ -24,7 +24,6 @@ from opaque_keys.edx.keys import CourseKey, UsageKey
from opaque_keys.edx.locations import AssetLocation, CourseLocator from opaque_keys.edx.locations import AssetLocation, CourseLocator
from path import Path as path from path import Path as path
from common.test.utils import XssTestMixin
from contentstore.tests.utils import AjaxEnabledTestClient, CourseTestCase, get_url, parse_json from contentstore.tests.utils import AjaxEnabledTestClient, CourseTestCase, get_url, parse_json
from contentstore.utils import delete_course, reverse_course_url, reverse_url from contentstore.utils import delete_course, reverse_course_url, reverse_url
from contentstore.views.component import ADVANCED_COMPONENT_TYPES from contentstore.views.component import ADVANCED_COMPONENT_TYPES
...@@ -1138,7 +1137,7 @@ class MiscCourseTests(ContentStoreTestCase): ...@@ -1138,7 +1137,7 @@ class MiscCourseTests(ContentStoreTestCase):
@ddt.ddt @ddt.ddt
class ContentStoreTest(ContentStoreTestCase, XssTestMixin): class ContentStoreTest(ContentStoreTestCase):
""" """
Tests for the CMS ContentStore application. Tests for the CMS ContentStore application.
""" """
...@@ -1473,33 +1472,6 @@ class ContentStoreTest(ContentStoreTestCase, XssTestMixin): ...@@ -1473,33 +1472,6 @@ class ContentStoreTest(ContentStoreTestCase, XssTestMixin):
item = ItemFactory.create(parent_location=course.location) item = ItemFactory.create(parent_location=course.location)
self.assertIsInstance(item, SequenceDescriptor) self.assertIsInstance(item, SequenceDescriptor)
def test_course_index_view_with_course(self):
"""Test viewing the index page with an existing course"""
CourseFactory.create(display_name='Robot Super Educational Course')
resp = self.client.get_html('/home/')
self.assertContains(
resp,
'<h3 class="course-title">Robot Super Educational Course</h3>',
status_code=200,
html=True
)
def test_course_index_view_xss(self):
"""Test that the index page correctly escapes course names with script
tags."""
CourseFactory.create(
display_name='<script>alert("course XSS")</script>'
)
LibraryFactory.create(display_name='<script>alert("library XSS")</script>')
resp = self.client.get_html('/home/')
for xss in ('course', 'library'):
html = '<script>alert("{name} XSS")</script>'.format(
name=xss
)
self.assert_no_xss(resp, html)
def test_course_overview_view_with_course(self): def test_course_overview_view_with_course(self):
"""Test viewing the course overview page with an existing course""" """Test viewing the course overview page with an existing course"""
course = CourseFactory.create() course = CourseFactory.create()
...@@ -1911,30 +1883,22 @@ class RerunCourseTest(ContentStoreTestCase): ...@@ -1911,30 +1883,22 @@ class RerunCourseTest(ContentStoreTestCase):
destination_course_key = CourseKey.from_string(json_resp['destination_course_key']) destination_course_key = CourseKey.from_string(json_resp['destination_course_key'])
return destination_course_key return destination_course_key
def get_course_listing_elements(self, html, course_key):
"""Returns the elements in the course listing section of html that have the given course_key"""
return html.cssselect('.course-item[data-course-key="{}"]'.format(unicode(course_key)))
def get_unsucceeded_course_action_elements(self, html, course_key): def get_unsucceeded_course_action_elements(self, html, course_key):
"""Returns the elements in the unsucceeded course action section that have the given course_key""" """Returns the elements in the unsucceeded course action section that have the given course_key"""
return html.cssselect('.courses-processing li[data-course-key="{}"]'.format(unicode(course_key))) return html.cssselect('.courses-processing li[data-course-key="{}"]'.format(unicode(course_key)))
def assertInCourseListing(self, course_key): def assertInCourseListing(self, course_key):
""" """
Asserts that the given course key is in the accessible course listing section of the html Asserts that the given course key is NOT in the unsucceeded course action section of the html.
and NOT in the unsucceeded course action section of the html.
""" """
course_listing = lxml.html.fromstring(self.client.get_html('/home/').content) course_listing = lxml.html.fromstring(self.client.get_html('/home/').content)
self.assertEqual(len(self.get_course_listing_elements(course_listing, course_key)), 1)
self.assertEqual(len(self.get_unsucceeded_course_action_elements(course_listing, course_key)), 0) self.assertEqual(len(self.get_unsucceeded_course_action_elements(course_listing, course_key)), 0)
def assertInUnsucceededCourseActions(self, course_key): def assertInUnsucceededCourseActions(self, course_key):
""" """
Asserts that the given course key is in the unsucceeded course action section of the html Asserts that the given course key is in the unsucceeded course action section of the html.
and NOT in the accessible course listing section of the html.
""" """
course_listing = lxml.html.fromstring(self.client.get_html('/home/').content) course_listing = lxml.html.fromstring(self.client.get_html('/home/').content)
self.assertEqual(len(self.get_course_listing_elements(course_listing, course_key)), 0)
self.assertEqual(len(self.get_unsucceeded_course_action_elements(course_listing, course_key)), 1) self.assertEqual(len(self.get_unsucceeded_course_action_elements(course_listing, course_key)), 1)
def verify_rerun_course(self, source_course_key, destination_course_key, destination_display_name): def verify_rerun_course(self, source_course_key, destination_course_key, destination_display_name):
......
...@@ -9,11 +9,9 @@ from ccx_keys.locator import CCXLocator ...@@ -9,11 +9,9 @@ from ccx_keys.locator import CCXLocator
from chrono import Timer from chrono import Timer
from django.conf import settings from django.conf import settings
from django.test import RequestFactory from django.test import RequestFactory
from django.test.client import Client
from mock import Mock, patch from mock import Mock, patch
from opaque_keys.edx.locations import CourseLocator from opaque_keys.edx.locations import CourseLocator
from common.test.utils import XssTestMixin
from contentstore.tests.utils import AjaxEnabledTestClient from contentstore.tests.utils import AjaxEnabledTestClient
from contentstore.utils import delete_course from contentstore.utils import delete_course
from contentstore.views.course import ( from contentstore.views.course import (
...@@ -44,7 +42,7 @@ USER_COURSES_COUNT = 1 ...@@ -44,7 +42,7 @@ USER_COURSES_COUNT = 1
@ddt.ddt @ddt.ddt
class TestCourseListing(ModuleStoreTestCase, XssTestMixin): class TestCourseListing(ModuleStoreTestCase):
""" """
Unit tests for getting the list of courses for a logged in user Unit tests for getting the list of courses for a logged in user
""" """
...@@ -88,30 +86,6 @@ class TestCourseListing(ModuleStoreTestCase, XssTestMixin): ...@@ -88,30 +86,6 @@ class TestCourseListing(ModuleStoreTestCase, XssTestMixin):
self.client.logout() self.client.logout()
ModuleStoreTestCase.tearDown(self) ModuleStoreTestCase.tearDown(self)
def test_course_listing_is_escaped(self):
"""
Tests course listing returns escaped data.
"""
escaping_content = "<script>alert('ESCAPE')</script>"
# Make user staff to access course listing
self.user.is_staff = True
self.user.save() # pylint: disable=no-member
self.client = Client()
self.client.login(username=self.user.username, password='test')
# Change 'display_coursenumber' field and update the course.
course = CourseFactory.create()
course.display_coursenumber = escaping_content
course = self.store.update_item(course, self.user.id) # pylint: disable=no-member
self.assertEqual(course.display_coursenumber, escaping_content)
# Check if response is escaped
response = self.client.get('/home')
self.assertEqual(response.status_code, 200)
self.assert_no_xss(response, escaping_content)
def test_empty_course_listing(self): def test_empty_course_listing(self):
""" """
Test on empty course listing, studio name is properly displayed Test on empty course listing, studio name is properly displayed
......
...@@ -52,55 +52,34 @@ class TestCourseIndex(CourseTestCase): ...@@ -52,55 +52,34 @@ class TestCourseIndex(CourseTestCase):
display_name='dotted.course.name-2', display_name='dotted.course.name-2',
) )
def check_index_and_outline(self, authed_client): def check_courses_on_index(self, authed_client):
""" """
Test getting the list of courses and then pulling up their outlines Test that the React course listing is present.
""" """
index_url = '/home/' index_url = '/home/'
index_response = authed_client.get(index_url, {}, HTTP_ACCEPT='text/html') index_response = authed_client.get(index_url, {}, HTTP_ACCEPT='text/html')
parsed_html = lxml.html.fromstring(index_response.content) parsed_html = lxml.html.fromstring(index_response.content)
course_link_eles = parsed_html.find_class('course-link') courses_tab = parsed_html.find_class('react-course-listing')
self.assertGreaterEqual(len(course_link_eles), 2) self.assertEqual(len(courses_tab), 1)
for link in course_link_eles:
self.assertRegexpMatches(
link.get("href"),
'course/{}'.format(settings.COURSE_KEY_PATTERN)
)
# now test that url
outline_response = authed_client.get(link.get("href"), {}, HTTP_ACCEPT='text/html')
# ensure it has the expected 2 self referential links
outline_parsed = lxml.html.fromstring(outline_response.content)
outline_link = outline_parsed.find_class('course-link')[0]
self.assertEqual(outline_link.get("href"), link.get("href"))
course_menu_link = outline_parsed.find_class('nav-course-courseware-outline')[0]
self.assertEqual(course_menu_link.find("a").get("href"), link.get("href"))
def test_libraries_on_course_index(self): def test_libraries_on_index(self):
""" """
Test getting the list of libraries from the course listing page Test that the library tab is present.
""" """
def _assert_library_link_present(response, library): def _assert_library_tab_present(response):
""" """
Asserts there's a valid library link on libraries tab. Asserts there's a library tab.
""" """
parsed_html = lxml.html.fromstring(response.content) parsed_html = lxml.html.fromstring(response.content)
library_link_elements = parsed_html.find_class('library-link') library_tab = parsed_html.find_class('react-library-listing')
self.assertEqual(len(library_link_elements), 1) self.assertEqual(len(library_tab), 1)
link = library_link_elements[0]
self.assertEqual(
link.get("href"),
reverse_library_url('library_handler', library.location.library_key),
)
# now test that url
outline_response = self.client.get(link.get("href"), {}, HTTP_ACCEPT='text/html')
self.assertEqual(outline_response.status_code, 200)
# Add a library: # Add a library:
lib1 = LibraryFactory.create() lib1 = LibraryFactory.create()
index_url = '/home/' index_url = '/home/'
index_response = self.client.get(index_url, {}, HTTP_ACCEPT='text/html') index_response = self.client.get(index_url, {}, HTTP_ACCEPT='text/html')
_assert_library_link_present(index_response, lib1) _assert_library_tab_present(index_response)
# Make sure libraries are visible to non-staff users too # Make sure libraries are visible to non-staff users too
self.client.logout() self.client.logout()
...@@ -109,13 +88,13 @@ class TestCourseIndex(CourseTestCase): ...@@ -109,13 +88,13 @@ class TestCourseIndex(CourseTestCase):
LibraryUserRole(lib2.location.library_key).add_users(non_staff_user) LibraryUserRole(lib2.location.library_key).add_users(non_staff_user)
self.client.login(username=non_staff_user.username, password=non_staff_userpassword) self.client.login(username=non_staff_user.username, password=non_staff_userpassword)
index_response = self.client.get(index_url, {}, HTTP_ACCEPT='text/html') index_response = self.client.get(index_url, {}, HTTP_ACCEPT='text/html')
_assert_library_link_present(index_response, lib2) _assert_library_tab_present(index_response)
def test_is_staff_access(self): def test_is_staff_access(self):
""" """
Test that people with is_staff see the courses and can navigate into them Test that people with is_staff see the courses and can navigate into them
""" """
self.check_index_and_outline(self.client) self.check_courses_on_index(self.client)
def test_negative_conditions(self): def test_negative_conditions(self):
""" """
...@@ -143,7 +122,7 @@ class TestCourseIndex(CourseTestCase): ...@@ -143,7 +122,7 @@ class TestCourseIndex(CourseTestCase):
) )
# test access # test access
self.check_index_and_outline(course_staff_client) self.check_courses_on_index(course_staff_client)
def test_json_responses(self): def test_json_responses(self):
outline_url = reverse_course_url('course_handler', self.course.id) outline_url = reverse_course_url('course_handler', self.course.id)
...@@ -402,31 +381,8 @@ class TestCourseIndexArchived(CourseTestCase): ...@@ -402,31 +381,8 @@ class TestCourseIndexArchived(CourseTestCase):
parsed_html = lxml.html.fromstring(index_response.content) parsed_html = lxml.html.fromstring(index_response.content)
course_tab = parsed_html.find_class('courses') course_tab = parsed_html.find_class('courses')
self.assertEqual(len(course_tab), 1) self.assertEqual(len(course_tab), 1)
course_links = course_tab[0].find_class('course-link')
course_titles = course_tab[0].find_class('course-title')
archived_course_tab = parsed_html.find_class('archived-courses') archived_course_tab = parsed_html.find_class('archived-courses')
self.assertEqual(len(archived_course_tab), 1 if separate_archived_courses else 0)
if separate_archived_courses:
# Archived courses should be separated from the main course list
self.assertEqual(len(archived_course_tab), 1)
archived_course_links = archived_course_tab[0].find_class('course-link')
archived_course_titles = archived_course_tab[0].find_class('course-title')
self.assertEqual(len(archived_course_links), 1)
self.assertEqual(len(archived_course_titles), 1)
self.assertEqual(archived_course_titles[0].text, 'Archived Course')
self.assertEqual(len(course_links), 2)
self.assertEqual(len(course_titles), 2)
self.assertEqual(course_titles[0].text, 'Active Course 1')
self.assertEqual(course_titles[1].text, 'Active Course 2')
else:
# Archived courses should be included in the main course list
self.assertEqual(len(archived_course_tab), 0)
self.assertEqual(len(course_links), 3)
self.assertEqual(len(course_titles), 3)
self.assertEqual(course_titles[0].text, 'Active Course 1')
self.assertEqual(course_titles[1].text, 'Active Course 2')
self.assertEqual(course_titles[2].text, 'Archived Course')
@ddt.data( @ddt.data(
# Staff user has course staff access # Staff user has course staff access
......
...@@ -106,6 +106,10 @@ FEATURES['ENABLE_DISCUSSION_SERVICE'] = False ...@@ -106,6 +106,10 @@ FEATURES['ENABLE_DISCUSSION_SERVICE'] = False
# We do not yet understand why this occurs. Setting this to true is a stopgap measure # We do not yet understand why this occurs. Setting this to true is a stopgap measure
USE_I18N = True USE_I18N = True
# Override the test stub webpack_loader that is installed in test.py.
INSTALLED_APPS = tuple(app for app in INSTALLED_APPS if app != 'openedx.tests.util.webpack_loader')
INSTALLED_APPS += ('webpack_loader',)
# Include the lettuce app for acceptance testing, including the 'harvest' django-admin command # Include the lettuce app for acceptance testing, including the 'harvest' django-admin command
# django.contrib.staticfiles used to be loaded by lettuce, now we must add it ourselves # django.contrib.staticfiles used to be loaded by lettuce, now we must add it ourselves
# django.contrib.staticfiles is not added to lms as there is a ^/static$ route built in to the app # django.contrib.staticfiles is not added to lms as there is a ^/static$ route built in to the app
......
...@@ -252,6 +252,10 @@ FEATURES = { ...@@ -252,6 +252,10 @@ FEATURES = {
# Whether or not the dynamic EnrollmentTrackUserPartition should be registered. # Whether or not the dynamic EnrollmentTrackUserPartition should be registered.
'ENABLE_ENROLLMENT_TRACK_USER_PARTITION': True, 'ENABLE_ENROLLMENT_TRACK_USER_PARTITION': True,
# Whether archived courses (courses with end dates in the past) should be
# shown in Studio in a separate list.
'ENABLE_SEPARATE_ARCHIVED_COURSES': True
} }
ENABLE_JASMINE = False ENABLE_JASMINE = False
......
module.exports = {
extends: 'eslint-config-edx',
root: true,
settings: {
'import/resolver': 'webpack',
},
};
/* global gettext */
/* eslint react/no-array-index-key: 0 */
import PropTypes from 'prop-types';
import React from 'react';
import ReactDOM from 'react-dom';
function CourseOrLibraryListing(props) {
const allowReruns = props.allowReruns;
const linkClass = props.linkClass;
const idBase = props.idBase;
return (
<ul className="list-courses">
{
props.items.map((item, i) =>
(
<li key={i} className="course-item" data-course-key={item.course_key}>
<a className={linkClass} href={item.url}>
<h3 className="course-title" id={`title-${idBase}-${i}`}>{item.display_name}</h3>
<div className="course-metadata">
<span className="course-org metadata-item">
<span className="label">{gettext('Organization:')}</span>
<span className="value">{item.org}</span>
</span>
<span className="course-num metadata-item">
<span className="label">{gettext('Course Number:')}</span>
<span className="value">{item.number}</span>
</span>
{ item.run &&
<span className="course-run metadata-item">
<span className="label">{gettext('Course Run:')}</span>
<span className="value">{item.run}</span>
</span>
}
{ item.can_edit === false &&
<span className="extra-metadata">{gettext('(Read-only)')}</span>
}
</div>
</a>
{ item.lms_link && item.rerun_link &&
<ul className="item-actions course-actions">
{ allowReruns &&
<li className="action action-rerun">
<a
href={item.rerun_link}
className="button rerun-button"
aria-labelledby={`re-run-${idBase}-${i} title-${idBase}-${i}`}
id={`re-run-${idBase}-${i}`}
>{gettext('Re-run Course')}</a>
</li>
}
<li className="action action-view">
<a
href={item.lms_link}
rel="external"
className="button view-button"
aria-labelledby={`view-live-${idBase}-${i} title-${idBase}-${i}`}
id={`view-live-${idBase}-${i}`}
>{gettext('View Live')}</a>
</li>
</ul>
}
</li>
),
)
}
</ul>
);
}
CourseOrLibraryListing.propTypes = {
allowReruns: PropTypes.bool.isRequired,
idBase: PropTypes.string.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
linkClass: PropTypes.string.isRequired,
};
export class StudioCourseIndex {
constructor(selector, context, allowReruns) {
// The HTML element is only conditionally shown, based on number of courses.
const element = document.querySelector(selector);
if (element) {
ReactDOM.render(
<CourseOrLibraryListing
items={context}
linkClass="course-link"
idBase="course"
allowReruns={allowReruns}
/>,
element,
);
}
}
}
export class StudioArchivedIndex {
constructor(selector, context, allowReruns) {
// The HTML element is only conditionally shown, based on number of archived courses.
const element = document.querySelector(selector);
if (element) {
ReactDOM.render(
<CourseOrLibraryListing
items={context}
linkClass="course-link"
idBase="archived"
allowReruns={allowReruns}
/>,
element,
);
}
}
}
export class StudioLibraryIndex {
constructor(selector, context) {
// The HTML element is only conditionally shown, based on number of libraries.
const element = document.querySelector(selector);
if (element) {
ReactDOM.render(
<CourseOrLibraryListing
items={context}
linkClass="library-link"
idBase="library"
allowReruns={false}
/>,
document.querySelector(selector),
);
}
}
}
...@@ -445,26 +445,6 @@ ...@@ -445,26 +445,6 @@
// STATE: hover/focus // STATE: hover/focus
&:hover { &:hover {
background: $paleYellow; background: $paleYellow;
.course-actions {
opacity: 1.0;
pointer-events: auto;
}
.view-live-button {
@extend %ui-depth3;
@extend %btn-primary-blue;
@extend %sizing;
@include transition(opacity $tmg-f2 ease-in-out 0);
@include box-sizing(border-box);
padding: ($baseline/2);
opacity: 0.0;
pointer-events: none;
}
.course-metadata {
opacity: 1.0;
}
} }
.course-link, .course-actions { .course-link, .course-actions {
...@@ -498,8 +478,8 @@ ...@@ -498,8 +478,8 @@
& + .metadata-item:before { & + .metadata-item:before {
content: "/"; content: "/";
margin-left: ($baseline/10); margin-left: ($baseline/4);
margin-right: ($baseline/10); margin-right: ($baseline/4);
color: $gray-l4; color: $gray-l4;
} }
...@@ -509,18 +489,15 @@ ...@@ -509,18 +489,15 @@
} }
.extra-metadata { .extra-metadata {
margin-left: ($baseline/10); margin-left: ($baseline/4);
} }
} }
.course-actions { .course-actions {
@include transition(opacity $tmg-f2 ease-in-out 0);
@extend %ui-depth3; @extend %ui-depth3;
position: static; position: static;
width: flex-grid(3, 9); width: flex-grid(3, 9);
@include text-align(right); @include text-align(right);
opacity: 0;
pointer-events: none;
.action { .action {
display: inline-block; display: inline-block;
...@@ -546,11 +523,6 @@ ...@@ -546,11 +523,6 @@
.action-rerun { .action-rerun {
margin-right: $baseline; margin-right: $baseline;
} }
.rerun-button {
font-weight: 600;
// TODO: sync up button styling and add secondary style here
}
} }
// CASE: is processing // CASE: is processing
......
...@@ -80,7 +80,7 @@ from openedx.core.djangolib.js_utils import js_escaped_string ...@@ -80,7 +80,7 @@ from openedx.core.djangolib.js_utils import js_escaped_string
</span> </span>
<span class="tip tip-error is-hidden"></span> <span class="tip tip-error is-hidden"></span>
</li> </li>
<li class="field text required" id="field-organization"> <li class="field text required">
<label for="rerun-course-org">${_("Organization")}</label> <label for="rerun-course-org">${_("Organization")}</label>
<input class="rerun-course-org" id="rerun-course-org" type="text" name="rerun-course-org" aria-required="true" value="${source_course_key.org}" placeholder="${_('e.g. UniversityX or OrganizationX')}" /> <input class="rerun-course-org" id="rerun-course-org" type="text" name="rerun-course-org" aria-required="true" value="${source_course_key.org}" placeholder="${_('e.g. UniversityX or OrganizationX')}" />
<span class="tip"> <span class="tip">
......
...@@ -3,10 +3,13 @@ ...@@ -3,10 +3,13 @@
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from openedx.core.djangolib.markup import HTML, Text from openedx.core.djangolib.markup import HTML, Text
from openedx.core.djangolib.js_utils import (
dump_js_escaped_json
)
%> %>
<%inherit file="base.html" /> <%inherit file="base.html" />
<%namespace name='static' file='static_content.html'/>
<%def name="online_help_token()"><% return "home" %></%def> <%def name="online_help_token()"><% return "home" %></%def>
<%block name="title">${_("{studio_name} Home").format(studio_name=settings.STUDIO_SHORT_NAME)}</%block> <%block name="title">${_("{studio_name} Home").format(studio_name=settings.STUDIO_SHORT_NAME)}</%block>
<%block name="bodyclass">is-signedin index view-dashboard</%block> <%block name="bodyclass">is-signedin index view-dashboard</%block>
...@@ -73,7 +76,7 @@ from openedx.core.djangolib.markup import HTML, Text ...@@ -73,7 +76,7 @@ from openedx.core.djangolib.markup import HTML, Text
<span class="tip" id="tip-new-course-name">${_("The public display name for your course. This cannot be changed, but you can set a different display name in Advanced Settings later.")}</span> <span class="tip" id="tip-new-course-name">${_("The public display name for your course. This cannot be changed, but you can set a different display name in Advanced Settings later.")}</span>
<span class="tip tip-error is-hiding" id="tip-error-new-course-name"></span> <span class="tip tip-error is-hiding" id="tip-error-new-course-name"></span>
</li> </li>
<li class="field text required" id="field-organization"> <li class="field text required">
<label for="new-course-org">${_("Organization")}</label> <label for="new-course-org">${_("Organization")}</label>
## Translators: This is an example for the name of the organization sponsoring a course, seen when filling out the form to create a new course. The organization name cannot contain spaces. ## Translators: This is an example for the name of the organization sponsoring a course, seen when filling out the form to create a new course. The organization name cannot contain spaces.
## Translators: "e.g. UniversityX or OrganizationX" is a placeholder displayed when user put no data into this field. ## Translators: "e.g. UniversityX or OrganizationX" is a placeholder displayed when user put no data into this field.
...@@ -149,7 +152,7 @@ from openedx.core.djangolib.markup import HTML, Text ...@@ -149,7 +152,7 @@ from openedx.core.djangolib.markup import HTML, Text
<span class="tip" id="tip-new-library-name">${_("The public display name for your library.")}</span> <span class="tip" id="tip-new-library-name">${_("The public display name for your library.")}</span>
<span class="tip tip-error is-hiding" id="tip-error-new-library-name"></span> <span class="tip tip-error is-hiding" id="tip-error-new-library-name"></span>
</li> </li>
<li class="field text required" id="field-organization"> <li class="field text required">
<label for="new-library-org">${_("Organization")}</label> <label for="new-library-org">${_("Organization")}</label>
<input class="new-library-org" id="new-library-org" type="text" name="new-library-org" required placeholder="${_('e.g. UniversityX or OrganizationX')}" aria-describedby="tip-new-library-org tip-error-new-library-org" /> <input class="new-library-org" id="new-library-org" type="text" name="new-library-org" required placeholder="${_('e.g. UniversityX or OrganizationX')}" aria-describedby="tip-new-library-org tip-error-new-library-org" />
<span class="tip" id="tip-new-library-org">${_("The public organization name for your library.")} ${_("This cannot be changed.")}</span> <span class="tip" id="tip-new-library-org">${_("The public organization name for your library.")} ${_("This cannot be changed.")}</span>
...@@ -321,41 +324,7 @@ from openedx.core.djangolib.markup import HTML, Text ...@@ -321,41 +324,7 @@ from openedx.core.djangolib.markup import HTML, Text
% endif % endif
%if len(courses) > 0 or optimization_enabled: %if len(courses) > 0 or optimization_enabled:
<div class="courses courses-tab active"> <div class="courses courses-tab react-course-listing active"></div>
<ul class="list-courses">
%for course_info in sorted(courses, key=lambda s: s['display_name'].lower() if s['display_name'] is not None else ''):
<li class="course-item" data-course-key="${course_info['course_key']}">
<a class="course-link" href="${course_info['url']}">
<h3 class="course-title">${course_info['display_name']}</h3>
<div class="course-metadata">
<span class="course-org metadata-item">
<span class="label">${_("Organization:")}</span> <span class="value">${course_info['org']}</span>
</span>
<span class="course-num metadata-item">
<span class="label">${_("Course Number:")}</span>
<span class="value">${course_info['number']}</span>
</span>
<span class="course-run metadata-item">
<span class="label">${_("Course Run:")}</span> <span class="value">${course_info['run']}</span>
</span>
</div>
</a>
<ul class="item-actions course-actions">
% if allow_course_reruns and rerun_creator_status and course_creator_status=='granted':
<li class="action action-rerun">
<a href="${course_info['rerun_link']}" class="button rerun-button">${_("Re-run Course")}</a>
</li>
% endif
<li class="action action-view">
<a href="${course_info['lms_link']}" rel="external" class="button view-button">${_("View Live")}</a>
</li>
</ul>
</li>
%endfor
</ul>
</div>
%else: %else:
<div class="notice notice-incontext notice-instruction notice-instruction-nocourses list-notices courses-tab active"> <div class="notice notice-incontext notice-instruction notice-instruction-nocourses list-notices courses-tab active">
...@@ -473,68 +442,11 @@ from openedx.core.djangolib.markup import HTML, Text ...@@ -473,68 +442,11 @@ from openedx.core.djangolib.markup import HTML, Text
% endif % endif
%if archived_courses: %if archived_courses:
<div class="archived-courses archived-courses-tab"> <div class="archived-courses react-archived-course-listing archived-courses-tab"></div>
<ul class="list-courses">
%for course_info in sorted(archived_courses, key=lambda s: s['display_name'].lower() if s['display_name'] is not None else ''):
<li class="course-item" data-course-key="${course_info['course_key']}">
<a class="course-link" href="${course_info['url']}">
<h3 class="course-title">${course_info['display_name']}</h3>
<div class="course-metadata">
<span class="course-org metadata-item">
<span class="label">${_("Organization:")}</span> <span class="value">${course_info['org']}</span>
</span>
<span class="course-num metadata-item">
<span class="label">${_("Course Number:")}</span>
<span class="value">${course_info['number']}</span>
</span>
<span class="course-run metadata-item">
<span class="label">${_("Course Run:")}</span> <span class="value">${course_info['run']}</span>
</span>
</div>
</a>
<ul class="item-actions course-actions">
% if allow_course_reruns and rerun_creator_status and course_creator_status=='granted':
<li class="action action-rerun">
<a href="${course_info['rerun_link']}" class="button rerun-button">${_("Re-run Course")}</a>
</li>
% endif
<li class="action action-view">
<a href="${course_info['lms_link']}" rel="external" class="button view-button">${_("View Live")}</a>
</li>
</ul>
</li>
%endfor
</ul>
</div>
%endif %endif
%if len(libraries) > 0: %if len(libraries) > 0 or optimization_enabled:
<div class="libraries libraries-tab"> <div class="libraries react-library-listing libraries-tab"></div>
<ul class="list-courses">
%for library_info in sorted(libraries, key=lambda s: s['display_name'].lower() if s['display_name'] is not None else ''):
<li class="course-item">
<a class="library-link" href="${library_info['url']}">
<h3 class="course-title">${library_info['display_name']}</h3>
<div class="course-metadata">
<span class="course-org metadata-item">
<span class="label">${_("Organization:")}</span> <span class="value">${library_info['org']}</span>
</span>
<span class="course-num metadata-item">
<span class="label">${_("Course Number:")}</span>
<span class="value">${library_info['number']}</span>
</span>
% if not library_info["can_edit"]:
<span class="extra-metadata">${_("(Read-only)")}</span>
% endif
</div>
</a>
</li>
%endfor
</ul>
</div>
%else: %else:
<div class="notice notice-incontext notice-instruction notice-instruction-nocourses list-notices libraries-tab"> <div class="notice notice-incontext notice-instruction notice-instruction-nocourses list-notices libraries-tab">
...@@ -640,4 +552,21 @@ from openedx.core.djangolib.markup import HTML, Text ...@@ -640,4 +552,21 @@ from openedx.core.djangolib.markup import HTML, Text
%endif %endif
</div> </div>
<%static:webpack entry="StudioIndex">
var enableReruns = ${allow_course_reruns and rerun_creator_status and course_creator_status=='granted' | n, dump_js_escaped_json};
new StudioCourseIndex(
".react-course-listing",
${sorted(courses, key=lambda s: s['display_name'].lower() if s['display_name'] is not None else '') | n, dump_js_escaped_json},
enableReruns
);
new StudioArchivedIndex(
".react-archived-course-listing",
${sorted(archived_courses, key=lambda s: s['display_name'].lower() if s['display_name'] is not None else '') | n, dump_js_escaped_json},
enableReruns
);
new StudioLibraryIndex(
".react-library-listing",
${sorted(libraries, key=lambda s: s['display_name'].lower() if s['display_name'] is not None else '') | n, dump_js_escaped_json}
);
</%static:webpack>
</%block> </%block>
...@@ -59,7 +59,7 @@ ...@@ -59,7 +59,7 @@
</span> </span>
<span class="tip tip-error is-hidden"></span> <span class="tip tip-error is-hidden"></span>
</li> </li>
<li class="field text required" id="field-organization"> <li class="field text required">
<label for="rerun-course-org">Organization</label> <label for="rerun-course-org">Organization</label>
<input class="rerun-course-org" id="rerun-course-org" type="text" <input class="rerun-course-org" id="rerun-course-org" type="text"
name="rerun-course-org" aria-required="true" name="rerun-course-org" aria-required="true"
......
...@@ -42,7 +42,7 @@ ...@@ -42,7 +42,7 @@
<span class="tip">The public display name for your course. This cannot be changed, but you can set a different display name in Advanced Settings later.</span> <span class="tip">The public display name for your course. This cannot be changed, but you can set a different display name in Advanced Settings later.</span>
<span class="tip tip-error is-hiding"></span> <span class="tip tip-error is-hiding"></span>
</li> </li>
<li class="field text required" id="field-organization"> <li class="field text required">
<label for="new-course-org">Organization</label> <label for="new-course-org">Organization</label>
<input class="new-course-org" id="new-course-org" type="text" name="new-course-org" aria-required="true" placeholder="e.g. UniversityX or OrganizationX" /> <input class="new-course-org" id="new-course-org" type="text" name="new-course-org" aria-required="true" placeholder="e.g. UniversityX or OrganizationX" />
<span class="tip">The name of the organization sponsoring the course. <strong>Note: This is part of your course URL, so no spaces or special characters are allowed.</strong> This cannot be changed, but you can set a different display name in Advanced Settings later.</span> <span class="tip">The name of the organization sponsoring the course. <strong>Note: This is part of your course URL, so no spaces or special characters are allowed.</strong> This cannot be changed, but you can set a different display name in Advanced Settings later.</span>
...@@ -96,7 +96,7 @@ ...@@ -96,7 +96,7 @@
<span class="tip">The public display name for your library.</span> <span class="tip">The public display name for your library.</span>
<span class="tip tip-error is-hiding"></span> <span class="tip tip-error is-hiding"></span>
</li> </li>
<li class="field text required" id="field-organization"> <li class="field text required">
<label for="new-library-org">Organization</label> <label for="new-library-org">Organization</label>
<input class="new-library-org" id="new-library-org" type="text" name="new-library-org" aria-required="true" placeholder="e.g. UniversityX or OrganizationX" /> <input class="new-library-org" id="new-library-org" type="text" name="new-library-org" aria-required="true" placeholder="e.g. UniversityX or OrganizationX" />
<span class="tip">The public organization name for your library. This cannot be changed.</span> <span class="tip">The public organization name for your library. This cannot be changed.</span>
......
...@@ -205,12 +205,13 @@ class DashboardPage(PageObject, HelpMixin): ...@@ -205,12 +205,13 @@ class DashboardPage(PageObject, HelpMixin):
) )
self.q(css='.ui-autocomplete .ui-menu-item a').filter(lambda el: el.text == item_text)[0].click() self.q(css='.ui-autocomplete .ui-menu-item a').filter(lambda el: el.text == item_text)[0].click()
def list_courses(self): def list_courses(self, archived=False):
""" """
List all the courses found on the page's list of libraries. List all the courses found on the page's list of courses.
""" """
# Workaround Selenium/Firefox bug: `.text` property is broken on invisible elements # Workaround Selenium/Firefox bug: `.text` property is broken on invisible elements
course_tab_link = self.q(css='#course-index-tabs .courses-tab a') tab_selector = '#course-index-tabs .{} a'.format('archived-courses-tab' if archived else 'courses-tab')
course_tab_link = self.q(css=tab_selector)
if course_tab_link: if course_tab_link:
course_tab_link.click() course_tab_link.click()
div2info = lambda element: { div2info = lambda element: {
...@@ -220,13 +221,14 @@ class DashboardPage(PageObject, HelpMixin): ...@@ -220,13 +221,14 @@ class DashboardPage(PageObject, HelpMixin):
'run': element.find_element_by_css_selector('.course-run .value').text, 'run': element.find_element_by_css_selector('.course-run .value').text,
'url': element.find_element_by_css_selector('a.course-link').get_attribute('href'), 'url': element.find_element_by_css_selector('a.course-link').get_attribute('href'),
} }
return self.q(css='.courses li.course-item').map(div2info).results course_list_selector = '.{} li.course-item'.format('archived-courses' if archived else 'courses')
return self.q(css=course_list_selector).map(div2info).results
def has_course(self, org, number, run): def has_course(self, org, number, run, archived=False):
""" """
Returns `True` if course for given org, number and run exists on the page otherwise `False` Returns `True` if course for given org, number and run exists on the page otherwise `False`
""" """
for course in self.list_courses(): for course in self.list_courses(archived):
if course['org'] == org and course['number'] == number and course['run'] == run: if course['org'] == org and course['number'] == number and course['run'] == run:
return True return True
return False return False
...@@ -245,6 +247,7 @@ class DashboardPage(PageObject, HelpMixin): ...@@ -245,6 +247,7 @@ class DashboardPage(PageObject, HelpMixin):
'name': element.find_element_by_css_selector('.course-title').text, 'name': element.find_element_by_css_selector('.course-title').text,
'org': element.find_element_by_css_selector('.course-org .value').text, 'org': element.find_element_by_css_selector('.course-org .value').text,
'number': element.find_element_by_css_selector('.course-num .value').text, 'number': element.find_element_by_css_selector('.course-num .value').text,
'link_element': element.find_element_by_css_selector('a.library-link'),
'url': element.find_element_by_css_selector('a.library-link').get_attribute('href'), 'url': element.find_element_by_css_selector('a.library-link').get_attribute('href'),
} }
self.wait_for_element_visibility('.libraries li.course-item', "Switch to library tab") self.wait_for_element_visibility('.libraries li.course-item', "Switch to library tab")
...@@ -259,6 +262,14 @@ class DashboardPage(PageObject, HelpMixin): ...@@ -259,6 +262,14 @@ class DashboardPage(PageObject, HelpMixin):
return True return True
return False return False
def click_library(self, name):
"""
Click on the library with the given name.
"""
for lib in self.list_libraries():
if lib['name'] == name:
lib['link_element'].click()
@property @property
def language_selector(self): def language_selector(self):
""" """
......
...@@ -97,6 +97,9 @@ class CreateCourseTest(AcceptanceTest): ...@@ -97,6 +97,9 @@ class CreateCourseTest(AcceptanceTest):
self.assertTrue(self.dashboard_page.has_course( self.assertTrue(self.dashboard_page.has_course(
org=self.course_org, number=self.course_number, run=self.course_run org=self.course_org, number=self.course_number, run=self.course_run
)) ))
# Click on the course listing and verify that the Studio course outline page opens.
self.dashboard_page.click_course_run(self.course_run)
course_outline_page.wait_for_page()
def test_create_course_with_existing_org_via_autocomplete(self): def test_create_course_with_existing_org_via_autocomplete(self):
""" """
......
""" """
Acceptance tests for Home Page (My Courses / My Libraries). Acceptance tests for Home Page (My Courses / My Libraries).
""" """
import datetime
from uuid import uuid4 from uuid import uuid4
from flaky import flaky from flaky import flaky
from opaque_keys.edx.locator import LibraryLocator from opaque_keys.edx.locator import LibraryLocator
from base_studio_test import StudioCourseTest
from common.test.acceptance.pages.common.auto_auth import AutoAuthPage from common.test.acceptance.pages.common.auto_auth import AutoAuthPage
from common.test.acceptance.pages.lms.account_settings import AccountSettingsPage from common.test.acceptance.pages.lms.account_settings import AccountSettingsPage
from common.test.acceptance.pages.studio.index import DashboardPage from common.test.acceptance.pages.studio.index import DashboardPage
from common.test.acceptance.pages.studio.library import LibraryEditPage from common.test.acceptance.pages.studio.library import LibraryEditPage
from common.test.acceptance.pages.studio.overview import CourseOutlinePage
from common.test.acceptance.tests.helpers import AcceptanceTest, get_selected_option_text, select_option_by_text from common.test.acceptance.tests.helpers import AcceptanceTest, get_selected_option_text, select_option_by_text
...@@ -60,6 +63,9 @@ class CreateLibraryTest(AcceptanceTest): ...@@ -60,6 +63,9 @@ class CreateLibraryTest(AcceptanceTest):
# Then go back to the home page and make sure the new library is listed there: # Then go back to the home page and make sure the new library is listed there:
self.dashboard_page.visit() self.dashboard_page.visit()
self.assertTrue(self.dashboard_page.has_library(name=name, org=org, number=number)) self.assertTrue(self.dashboard_page.has_library(name=name, org=org, number=number))
# Click on the library listing and verify that the library edit view loads.
self.dashboard_page.click_library(name)
lib_page.wait_for_page()
class StudioLanguageTest(AcceptanceTest): class StudioLanguageTest(AcceptanceTest):
...@@ -95,3 +101,44 @@ class StudioLanguageTest(AcceptanceTest): ...@@ -95,3 +101,44 @@ class StudioLanguageTest(AcceptanceTest):
get_selected_option_text(language_selector), get_selected_option_text(language_selector),
u'Dummy Language (Esperanto)' u'Dummy Language (Esperanto)'
) )
class ArchivedCourseTest(StudioCourseTest):
""" Tests that archived courses appear in their own list. """
def setUp(self, is_staff=True, test_xss=False):
"""
Load the helper for the home page (dashboard page)
"""
super(ArchivedCourseTest, self).setUp(is_staff=is_staff, test_xss=test_xss)
self.dashboard_page = DashboardPage(self.browser)
def populate_course_fixture(self, course_fixture):
current_time = datetime.datetime.now()
course_start_date = current_time - datetime.timedelta(days=60)
course_end_date = current_time - datetime.timedelta(days=90)
course_fixture.add_course_details({
'start_date': course_start_date,
'end_date': course_end_date
})
def test_archived_course(self):
"""
Scenario: Ensure that an archived course displays in its own list and can be clicked on.
"""
self.dashboard_page.visit()
self.assertTrue(self.dashboard_page.has_course(
org=self.course_info['org'], number=self.course_info['number'], run=self.course_info['run'],
archived=True
))
# Click on the archived course and make sure that the Studio course outline appears.
self.dashboard_page.click_course_run(self.course_info['run'])
course_outline_page = CourseOutlinePage(
self.browser,
self.course_info['org'],
self.course_info['number'],
self.course_info['run']
)
course_outline_page.wait_for_page()
...@@ -5,6 +5,7 @@ ...@@ -5,6 +5,7 @@
"babel-core": "^6.23.0", "babel-core": "^6.23.0",
"babel-loader": "^6.4.0", "babel-loader": "^6.4.0",
"babel-preset-env": "^1.2.1", "babel-preset-env": "^1.2.1",
"babel-preset-react": "^6.24.1",
"backbone": "~1.3.2", "backbone": "~1.3.2",
"backbone.paginator": "~2.0.3", "backbone.paginator": "~2.0.3",
"coffee-loader": "^0.7.3", "coffee-loader": "^0.7.3",
...@@ -21,7 +22,10 @@ ...@@ -21,7 +22,10 @@
"moment": "^2.15.1", "moment": "^2.15.1",
"moment-timezone": "~0.5.5", "moment-timezone": "~0.5.5",
"picturefill": "~3.0.2", "picturefill": "~3.0.2",
"prop-types": "^15.5.10",
"raw-loader": "^0.5.1", "raw-loader": "^0.5.1",
"react": "^15.5.4",
"react-dom": "^15.5.4",
"requirejs": "~2.3.2", "requirejs": "~2.3.2",
"string-replace-webpack-plugin": "^0.1.3", "string-replace-webpack-plugin": "^0.1.3",
"uglify-js": "2.7.0", "uglify-js": "2.7.0",
......
...@@ -288,7 +288,7 @@ def run_eslint(options): ...@@ -288,7 +288,7 @@ def run_eslint(options):
violations_limit = int(getattr(options, 'limit', -1)) violations_limit = int(getattr(options, 'limit', -1))
sh( sh(
"eslint --format=compact . | tee {eslint_report}".format( "eslint --ext .js --ext .jsx --format=compact . | tee {eslint_report}".format(
eslint_report=eslint_report eslint_report=eslint_report
), ),
ignore_error=True ignore_error=True
......
...@@ -12,7 +12,7 @@ set -e ...@@ -12,7 +12,7 @@ set -e
# Violations thresholds for failing the build # Violations thresholds for failing the build
export PYLINT_THRESHOLD=3600 export PYLINT_THRESHOLD=3600
export ESLINT_THRESHOLD=9190 export ESLINT_THRESHOLD=9134
XSSLINT_THRESHOLDS=`cat scripts/xsslint_thresholds.json` XSSLINT_THRESHOLDS=`cat scripts/xsslint_thresholds.json`
export XSSLINT_THRESHOLDS=${XSSLINT_THRESHOLDS//[[:space:]]/} export XSSLINT_THRESHOLDS=${XSSLINT_THRESHOLDS//[[:space:]]/}
......
...@@ -24,7 +24,8 @@ var wpconfig = { ...@@ -24,7 +24,8 @@ var wpconfig = {
CourseTalkReviews: './openedx/features/course_experience/static/course_experience/js/CourseTalkReviews.js', CourseTalkReviews: './openedx/features/course_experience/static/course_experience/js/CourseTalkReviews.js',
WelcomeMessage: './openedx/features/course_experience/static/course_experience/js/WelcomeMessage.js', WelcomeMessage: './openedx/features/course_experience/static/course_experience/js/WelcomeMessage.js',
Enrollment: './openedx/features/course_experience/static/course_experience/js/Enrollment.js', Enrollment: './openedx/features/course_experience/static/course_experience/js/Enrollment.js',
Import: './cms/static/js/features/import/factories/import.js' Import: './cms/static/js/features/import/factories/import.js',
StudioIndex: './cms/static/js/features_jsx/studio/index.jsx'
}, },
output: { output: {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment