Skip to content

Commit

Permalink
feat: Handle JSInput extra files when copying/pasting (#32847)
Browse files Browse the repository at this point in the history
This takes into account the extra files that are usually required when
copying problems containing JSInputs. Static files such as additional
CSS and JS files needed to interact and style the problem.
  • Loading branch information
yusuf-musleh committed Jul 31, 2023
1 parent 6598abb commit b97007e
Show file tree
Hide file tree
Showing 7 changed files with 275 additions and 0 deletions.
11 changes: 11 additions & 0 deletions common/test/data/uploads/simple-question.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/* Original Source: https://files.edx.org/custom-js-example/jsinput_example.css */

.directions {
font-size: large
}

.feedback {
font-size: medium;
border: 2px solid cornflowerblue;
padding: 5px;
}
18 changes: 18 additions & 0 deletions common/test/data/uploads/simple-question.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<!-- Original Source: https://files.edx.org/custom-js-example/jsinput_example.html -->

<!DOCTYPE html>
<html lang="en">
<head>
<title>Simple Question</title>
<link rel="stylesheet" type="text/css" href="simple-question.css">
</head>
<body>
<script src="jschannel.js"></script>
<script src="simple-question.js" defer></script>
<img src="image.jpg" />
<label class="directions">Here is a list below, please select:
<select class="choices"></select>
</label>
<p aria-live="polite" class="feedback"></p>
</body>
</html>
88 changes: 88 additions & 0 deletions common/test/data/uploads/simple-question.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/* Original Source: https://files.edx.org/custom-js-example/jsinput_example.js */

/* globals Channel */

(function() {
'use strict';

// state will be populated via initial_state via the `setState` method. Defining dummy values here
// to make the expected structure clear.
var state = {
availableChoices: [],
selectedChoice: ''
},
channel,
select = document.getElementsByClassName('choices')[0],
feedback = document.getElementsByClassName('feedback')[0];

function populateSelect() {
// Populate the select from `state.availableChoices`.
var i, option;

// Clear out any pre-existing options.
while (select.firstChild) {
select.removeChild(select.firstChild);
}

// Populate the select with the available choices.
for (i = 0; i < state.availableChoices.length; i++) {
option = document.createElement('option');
option.value = i;
option.innerHTML = state.availableChoices[i];
if (state.availableChoices[i] === state.selectedChoice) {
option.selected = true;
}
select.appendChild(option);
}
feedback.innerText = "The currently selected answer is '" + state.selectedChoice + "'.";
}

function getGrade() {
// The following return value may or may not be used to grade server-side.
// If getState and setState are used, then the Python grader also gets access
// to the return value of getState and can choose it instead to grade.
return JSON.stringify(state.selectedChoice);
}

function getState() {
// Returns the current state (which can be used for grading).
return JSON.stringify(state);
}

// This function will be called with 1 argument when JSChannel is not used,
// 2 otherwise. In the latter case, the first argument is a transaction
// object that will not be used here
// (see http://mozilla.github.io/jschannel/docs/)
function setState() {
var stateString = arguments.length === 1 ? arguments[0] : arguments[1];
state = JSON.parse(stateString);
populateSelect();
}

// Establish a channel only if this application is embedded in an iframe.
// This will let the parent window communicate with this application using
// RPC and bypass SOP restrictions.
if (window.parent !== window) {
channel = Channel.build({
window: window.parent,
origin: '*',
scope: 'JSInput'
});

channel.bind('getGrade', getGrade);
channel.bind('getState', getState);
channel.bind('setState', setState);
}

select.addEventListener('change', function() {
state.selectedChoice = select.options[select.selectedIndex].text;
feedback.innerText = "You have selected '" + state.selectedChoice +
"'. Click Submit to grade your answer.";
});

return {
getState: getState,
setState: setState,
getGrade: getGrade
};
}());
4 changes: 4 additions & 0 deletions openedx/core/lib/xblock_serializer/block_serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ def __init__(self, block):
if py_lib_zip_file:
self.static_files.append(py_lib_zip_file)

js_input_files = utils.get_js_input_files_if_using(self.olx_str, course_key)
for js_input_file in js_input_files:
self.static_files.append(js_input_file)

def _serialize_block(self, block) -> etree.Element:
""" Serialize an XBlock to OLX/XML. """
if block.scope_ids.usage_id.block_type == 'html':
Expand Down
43 changes: 43 additions & 0 deletions openedx/core/lib/xblock_serializer/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -244,3 +244,46 @@ def test_capa_python_lib(self):
</problem>
"""
)

def test_jsinput_extra_files(self):
"""
Test JSInput problems with extra static files.
"""
course = CourseFactory.create(display_name='JSInput Testing course', run="JSI")
jsinput_files = [
("simple-question.html", "./common/test/data/uploads/simple-question.html"),
("simple-question.js", "./common/test/data/uploads/simple-question.js"),
("simple-question.css", "./common/test/data/uploads/simple-question.css"),
("image.jpg", "./common/test/data/uploads/image.jpg"),
("jschannel.js", "./common/static/js/capa/src/jschannel.js"),
]
for filename, full_path in jsinput_files:
upload_file_to_course(
course_key=course.id,
contentstore=contentstore(),
source_file=full_path,
target_filename=filename,
)

jsinput_problem = BlockFactory.create(
parent_location=course.location,
category="problem",
display_name="JSInput Problem",
data="<problem><jsinput html_file='/static/simple-question.html' /></problem>",
)

# The jsinput problem should contain the html_file along with extra static files:

serialized = api.serialize_xblock_to_olx(jsinput_problem)
assert len(serialized.static_files) == 5
for file in serialized.static_files:
self.assertIn(file.name, list(map(lambda f: f[0], jsinput_files)))

self.assertXmlEqual(
serialized.olx_str,
"""
<problem display_name="JSInput Problem" url_name="JSInput_Problem">
<jsinput html_file='/static/simple-question.html' />
</problem>
"""
)
59 changes: 59 additions & 0 deletions openedx/core/lib/xblock_serializer/test_utils.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""
Test the OLX serialization utils
"""
from __future__ import annotations
import unittest

import ddt
Expand Down Expand Up @@ -63,3 +64,61 @@ def test_has_python_script(self, olx: str, has_script: bool):
Test the _has_python_script() helper
"""
assert utils._has_python_script(olx) == has_script # pylint: disable=protected-access

@ddt.unpack
@ddt.data(
('''<problem><jsinput html_file="/static/question.html"/></problem>''', "/static/question.html"),
('''<problem><jsinput html_file="/static/simple-question.html"/></problem>''', "/static/simple-question.html"),
('''<problem><jsinput html_file='/static/simple-question.html'/></problem>''', "/static/simple-question.html"),
('''<problem><jsinput html_file='/static/simple.question.html'/></problem>''', "/static/simple.question.html"),
('''<problem><jsinput html_file="example.com/static/simple-question.html"/></problem>''', None),
('''<problem><jsinput html_file="https://example.com/static/simple-question.html"/></problem>''', None),
('''<problem><jsinput html_file="https://example.com/static/simple-question.html"/></problem>''', None),
('''<problem><jsinput />some url: /static/simple-question.html</problem>''', None),
)
def test_extract_local_html_path(self, olx: str, local_html_path: str | None):
"""
Test the _extract_local_html_path() helper. Confirm that it correctly detects the
presence of a `/static/` url in the 'html_file` attribute of a `<jsinput>` tag.
"""
assert utils._extract_local_html_path(olx) == local_html_path # pylint: disable=protected-access

def test_extract_static_assets(self):
"""
Test the _extract_static_assets() helper. Confirm that it correctly extracts all the
static assets that have relative paths present in the html file.
"""
html_file_content = """
<!DOCTYPE html>
<html lang="en">
<head>
<title>Example Title</title>
<link
rel="stylesheet"
type="text/css"
href="simple-question.css">
</head>
<body>
<p>This is a non-existent css file: fake.css</p>
<script src="jsChannel.js"></script>
<script src="/some/path/simple-question.min.js" defer></script>
<script src="other/path/simple-question.min.js" defer></script>
<script src='http://example.com/static/external.js'></script>
<script src='https://example.com/static/external.js'></script>
<img src='mario.png' />
<label class="directions">Please select:
<select class="choices"></select>
</label>
<p aria-live="polite" class="feedback"></p>
</body>
</html>
"""
expected = [
"simple-question.css",
"jsChannel.js",
"/some/path/simple-question.min.js",
"other/path/simple-question.min.js",
"mario.png"
]
assert utils._extract_static_assets(html_file_content) == expected # pylint: disable=protected-access
52 changes: 52 additions & 0 deletions openedx/core/lib/xblock_serializer/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,58 @@ def _has_python_script(olx: str) -> bool:
return False


def get_js_input_files_if_using(olx: str, course_id: CourseKey) -> [StaticFile]:
"""
When a problem uses JSInput and references an html file uploaded to the course (i.e. uses /static/),
all the other related static asset files that it depends on should also be included.
"""
static_files = []
html_file_fullpath = _extract_local_html_path(olx)
if html_file_fullpath:
html_filename = html_file_fullpath.split('/')[-1]
asset_key = StaticContent.get_asset_key_from_path(course_id, html_filename)
html_file_content = AssetManager.find(asset_key, throw_on_not_found=False)
if html_file_content:
static_assets = _extract_static_assets(str(html_file_content.data))
for static_asset in static_assets:
url = '/' + str(StaticContent.compute_location(course_id, static_asset))
static_files.append(StaticFile(name=static_asset, url=url, data=None))

return static_files


def _extract_static_assets(html_file_content_data: str) -> [str]:
"""
Extracts all the static assets with relative paths that are present in the html content
"""
# Regular expression that looks for URLs that are inside HTML tag
# attributes (src or href) with relative paths.
# The pattern looks for either src or href, followed by an equals sign
# and then captures everything until it finds the closing quote (single or double)
assets_re = r'\b(?:src|href)\s*=\s*(?![\'"]?(?:https?://))["\']([^\'"]*?\.[^\'"]*?)["\']'

# Find all matches in the HTML code
matches = re.findall(assets_re, html_file_content_data)

return matches


def _extract_local_html_path(olx: str) -> str | None:
"""
Check if the given OlX <problem> block string contains a `jsinput` tag and the `html_file` attribute
is referencing a file in `/static/`. If so, extract the relative path of the html file in the OLX
"""
if "<jsinput" in olx:
# Regular expression to match html_file="/static/[anything].html" in both single and double quotes and
# extract the "/static/[anything].html" part from the input strings.
local_html_file_re = r'html_file=([\"\'])(?P<url>\/static\/[^\"\']*\.html)\1'
matches = re.search(local_html_file_re, olx)
if matches:
return matches.group('url') # Output example: /static/question.html

return None


@contextmanager
def override_export_fs(block):
"""
Expand Down

0 comments on commit b97007e

Please sign in to comment.