Skip to content

Commit

Permalink
feat: add left navigation menu for course (#54)
Browse files Browse the repository at this point in the history
* feat: add left navigation menu for course
  • Loading branch information
Danyal-Faheem authored Jan 17, 2024
1 parent eacc4f6 commit f7eed8c
Show file tree
Hide file tree
Showing 6 changed files with 245 additions and 40 deletions.
118 changes: 117 additions & 1 deletion openedxscorm/scormxblock.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ def scorm_storage(xblock):
icon_class = String(default="video", scope=Scope.settings)
width = Integer(
display_name=_("Display width (px)"),
help=_("Width of iframe (default: 100%%)"),
help=_("Width of iframe (default: 100%)"),
scope=Scope.settings,
)
height = Integer(
Expand All @@ -148,7 +148,23 @@ def scorm_storage(xblock):
default=False,
scope=Scope.settings,
)
enable_navigation_menu = Boolean(
display_name=_("Display navigation menu"),
help=_(
"Select True to display a navigation menu on the left side to display table of contents"
),
default=False,
scope=Scope.settings,
)

navigation_menu = String(scope=Scope.settings, default="")

navigation_menu_width = Integer(
display_name=_("Display width of navigation menu(px)"),
help=_("Width of navigation menu. This assumes that Navigation Menu is enabled. (default: 30%)"),
scope=Scope.settings,
)

has_author_view = True

def render_template(self, template_path, context):
Expand Down Expand Up @@ -184,6 +200,7 @@ def student_view(self, context=None):
"grade": self.get_grade(),
"can_view_student_reports": self.can_view_student_reports,
"scorm_xblock": self,
"navigation_menu": self.navigation_menu,
}
student_context.update(context or {})
template = self.render_template("static/html/scormxblock.html", student_context)
Expand Down Expand Up @@ -239,6 +256,8 @@ def studio_view(self, context=None):
"field_width": self.fields["width"],
"field_height": self.fields["height"],
"field_popup_on_launch": self.fields["popup_on_launch"],
"field_enable_navigation_menu": self.fields["enable_navigation_menu"],
"field_navigation_menu_width": self.fields["navigation_menu_width"],
"scorm_xblock": self,
}
studio_context.update(context or {})
Expand All @@ -261,6 +280,8 @@ def studio_submit(self, request, _suffix):
self.width = parse_int(request.params["width"], None)
self.height = parse_int(request.params["height"], None)
self.has_score = request.params["has_score"] == "1"
self.enable_navigation_menu = request.params["enable_navigation_menu"] == "1"
self.navigation_menu_width = parse_int(request.params["navigation_menu_width"], None)
self.weight = parse_float(request.params["weight"], 1)
self.popup_on_launch = request.params["popup_on_launch"] == "1"
self.icon_class = "problem" if self.has_score else "video"
Expand Down Expand Up @@ -517,6 +538,8 @@ def update_package_fields(self):
schemaversion = root.find(
"{prefix}metadata/{prefix}schemaversion".format(prefix=prefix)
)

self.extract_navigation_titles(root, prefix)

if resource is not None:
self.index_page_path = resource.get("href")
Expand All @@ -529,6 +552,99 @@ def update_package_fields(self):
else:
self.scorm_version = "SCORM_12"

def extract_navigation_titles(self, root, prefix):
"""Extracts all the titles of items to build a navigation menu from the imsmanifest.xml file
Args:
root (XMLTag): root of the imsmanifest.xml file
prefix (string): namespace to match with in the xml file
"""
organizations = root.findall('{prefix}organizations/{prefix}organization'.format(prefix=prefix))
navigation_menu_titles = []
# Get data for all organizations
for organization in organizations:
navigation_menu_titles.append(self.find_titles_recursively(organization, prefix, root))
self.navigation_menu = self.recursive_unorderedlist(navigation_menu_titles)

def sanitize_input(self, input_str):
"""Removes script tags from string"""
sanitized_str = re.sub(r'<script\b[^>]*>(.*?)</script>', '', input_str, flags=re.IGNORECASE)
return sanitized_str


def find_titles_recursively(self, item, prefix, root):
"""Recursively iterate through the organization tags and extract the title and resources
Args:
item (XMLTag): The current node to iterate on
prefix (string): namespace to match with in the xml file
root (XMLTag): root of the imsmanifest.xml file
Returns:
List: Nested list of all the title tags and their resources
"""
children = item.findall('{prefix}item'.format(prefix=prefix))
item_title = item.find('{prefix}title'.format(prefix=prefix)).text
# Sanitizing every title tag to protect against XSS attacks
sanitized_title = self.sanitize_input(item_title)
item_identifier = item.get("identifierref")
# If item does not have a resource, we don't need to make it into a link
if not item_identifier:
resource_link = "#"
else:
resource = root.find("{prefix}resources/{prefix}resource[@identifier='{identifier}']".format(prefix=prefix, identifier=item_identifier))
# Attach the storage path with the file path
resource_link = self.storage.url(os.path.join(self.extract_folder_path, resource.get("href")))
if not children:
return [(sanitized_title, resource_link)]
child_titles = []
for child in children:
if 'isvisible' in child.attrib and child.attrib['isvisible'] == "true":
child_titles.extend(self.find_titles_recursively(child, prefix, root))
return [(sanitized_title, resource_link), child_titles]

def recursive_unorderedlist(self, value):
"""Create an HTML unordered list recursively to display navigation menu
Args:
value (list): The nested list to create the unordered list
"""

def has_children(item):
return len(item) == 2 and (type(item[0]) is tuple and type(item[1]) is list)

def format(items, tabs=1):
"""Iterate through the nested list and return a formatted unordered list"""
indent = "\t" * tabs
# If leaf node, return the li tag
if type(items) is tuple:
title, resource_url = items[0], items[1]
if resource_url != "#":
return "{indent}<li href='{resource_url}' class='navigation-title'>{title}</li>".format(indent=indent, resource_url=resource_url, title=title)
return "{indent}<li class='navigation-title-header'>{title}</li>".format(indent=indent, title=title)

output = []
# If parent node, create another nested unordered list and return
if has_children(items):
parent, children = items[0], items[1]
title, resource_url = parent[0], parent[1]
for child in children:
output.append(format(child, tabs+1))
if resource_url != "#":
return "\n{indent}<ul>\n{indent}<li href='{resource_url}' class='navigation-title'>{title}</li>\n{indent}<ul>\n{indent}\n{output}</ul>\n{indent}</ul>".format(indent=indent, resource_url=resource_url, title=title, output="\n".join(output))
return "\n{indent}<ul>\n{indent}<li class='navigation-title-header'>{title}</li>\n{indent}<ul>\n{indent}\n{output}</ul>\n{indent}</ul>".format(indent=indent, resource_url=resource_url, title=title, output="\n".join(output))
else:
for item in items:
output.append(format(item, tabs+1))
return "{indent}\n{indent}<ul>\n{output}\n{indent}</ul>".format(indent=indent, output="\n".join(output))

unordered_lists = []
# Append navigation menus for all organizations in course
for organization in value:
unordered_lists.append(format(organization))

return "\n".join(unordered_lists)

def find_relative_file_path(self, filename):
return os.path.relpath(self.find_file_path(filename), self.extract_folder_path)

Expand Down
36 changes: 35 additions & 1 deletion openedxscorm/static/css/scormxblock.css
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,26 @@
height: 35px;
}

.scorm-xblock .scorm-content .scorm-panel {
display: flex;
flex-direction: row;
}

.scorm-xblock .scorm-content .scorm-panel .scorm-pane {
width: 100%;
}

.scorm-xblock.navigation-enabled .scorm-content .scorm-panel .scorm-pane {
width: 70%;
}

.navigation-title:hover {
cursor: pointer;
text-decoration: underline;
}

/* Fullscreen */
.scorm-xblock.fullscreen-enabled .scorm-embedded {
position: fixed;
border: none;
overflow: hidden;
top: 55px;
Expand All @@ -27,6 +44,23 @@
height: calc(100% - 65px);
z-index: 10000;
}

.scorm-xblock.fullscreen-enabled.navigation-enabled .scorm-content .scorm-panel .scorm-pane {
width: 100%;
}

.scorm-xblock.fullscreen-enabled.navigation-enabled .navigation_pane {
border: none;
overflow: hidden;
top: 55px;
right: 0;
bottom: 0;
left: 0;
height: calc(100% - 65px);
z-index: 10000;
}


.scorm-xblock.fullscreen-enabled .scorm-content {
position: fixed;
border: none;
Expand Down
82 changes: 49 additions & 33 deletions openedxscorm/static/html/scormxblock.html
Original file line number Diff line number Diff line change
@@ -1,48 +1,64 @@
{% load i18n %}

<div class="scorm-xblock">
{% if index_page_url %}
<div class="scorm-xblock {% if scorm_xblock.enable_navigation_menu %}navigation-enabled{% endif %}">
{% if index_page_url %} {% if scorm_xblock.has_score %}
<p>
(<span class="grade">{{ grade|floatformat }}</span>/{{ scorm_xblock.weight|floatformat }} {% trans "points" %})
<span class="completion-status">{% trans completion_status %}</span>
</p>
{% endif %}

{% if scorm_xblock.has_score %}
<p>
(<span class="grade">{{ grade|floatformat }}</span>/{{ scorm_xblock.weight|floatformat }} {% trans "points" %})
<span class="completion-status">{% trans completion_status %}</span>
</p>
{% endif %}
<div class="scorm-content">

<div class="fullscreen-controls">
<button class="enter-fullscreen">{% trans "Fullscreen" %}</button>
<button class="exit-fullscreen">{% trans "Exit fullscreen" %}</button>
</div>

<div class="scorm-panel">

<div class="scorm-content">
<div class="fullscreen-controls">
<button class="enter-fullscreen">
{% trans "Fullscreen" %}
</button>
<button class="exit-fullscreen">
{% trans "Exit fullscreen" %}
</button>
</div>
{% if scorm_xblock.enable_navigation_menu %}
<div class="navigation-pane" style="width: {% if scorm_xblock.navigation_menu_width %}{{scorm_xblock.navigation_menu_width}}px{% else %}30%{% endif %};">
<h4>Table of contents</h4>

<div class="popup-wrapper">
<button class="popup-launcher">{% trans "Launch unit in new window" %} <span class="icon fa fa-external-link"></span></button>
</div>
<iframe
<ul>
{{ navigation_menu|safe }}
</ul>
</div>
{% endif %}
<div class="scorm-pane" style="width: {% if scorm_xblock.width %}{{ scorm_xblock.width }}px{% else %}100%{% endif %}; height: {% if scorm_xblock.height %}{{ scorm_xblock.height }}px{% else %}450{% endif %};">
<div class="popup-wrapper">
<button class="popup-launcher">
{% trans "Launch unit in new window" %}
<span class="icon fa fa-external-link"></span>
</button>
</div>
<iframe
class="scorm-embedded"
src="{{ index_page_url }}"
width="{% if scorm_xblock.width %}{{ scorm_xblock.width }}{% else %}100%{% endif %}"
height="{% if scorm_xblock.height %}{{ scorm_xblock.height }}{% else %}450{% endif %}">
width="100%"
height="100%"
>
</iframe>
</div>
</div>
{% elif message %}
<p>{{ message }}</p>
{% endif %}
{% if can_view_student_reports %}
{% endif %} {% if can_view_student_reports %}
<div class="scorm-reports">
<button class="view-reports reports-togglable">
{% trans "View SCORM reports" %}
</button>
<span class="reports-togglable reports-togglable-off">
<input type="text" placeholder="Student username or email" class="search-students">
<button class="reload-report reports-togglable-off" alt="reload report">🗘</button>
<div class="report"></div>
</span>
<button class="view-reports reports-togglable">
{% trans "View SCORM reports" %}
</button>
<span class="reports-togglable reports-togglable-off">
<input
type="text"
placeholder="Student username or email"
class="search-students"
/>
<button class="reload-report reports-togglable-off" alt="reload report"></button>
<div class="report"></div>
</span>
</div>
{% endif %}
</div>
</div>
19 changes: 19 additions & 0 deletions openedxscorm/static/html/studio.html
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,17 @@
<span class="tip setting-help">{% trans field_has_score.help %}</span>
</li>

<li class="field comp-setting-entry is-set">
<div class="wrapper-comp-setting">
<label class="label setting-label" for="enable_navigation_menu">{% trans field_enable_navigation_menu.display_name %}</label>
<select name="enable_navigation_menu" id="enable_navigation_menu">
<option value="1" {% if scorm_xblock.enable_navigation_menu %}selected{% endif %}>{% trans "True" %}</option>
<option value="0" {% if not scorm_xblock.enable_navigation_menu %}selected{% endif %}>{% trans "False" %}</option>
</select>
</div>
<span class="tip setting-help">{% trans field_enable_navigation_menu.help %}</span>
</li>

<li class="field comp-setting-entry is-set">
<div class="wrapper-comp-setting">
<label class="label setting-label" for="weight">{% trans field_weight.display_name %}</label>
Expand All @@ -55,6 +66,14 @@
</div>
<span class="tip setting-help">{% trans field_height.help %}</span>
</li>

<li class="field comp-setting-entry is-set">
<div class="wrapper-comp-setting">
<label class="label setting-label" for="navigation_menu_width">{% trans field_navigation_menu_width.display_name %}</label>
<input class="input setting-input" name="navigation_menu_width" id="navigation_menu_width" value="{{ scorm_xblock.navigation_menu_width }}" type="number" />
</div>
<span class="tip setting-help">{% trans field_navigation_menu_width.help %}</span>
</li>
</ul>

<div class="xblock-actions">
Expand Down
26 changes: 21 additions & 5 deletions openedxscorm/static/js/src/scormxblock.js
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,14 @@ function ScormXBlock(runtime, element, settings) {
});
}

// Flag to verify if navigation menu was used to change page
var navigationClick = false;
$(element).on("click",".navigation-title", function () {
var path = $(this).attr('href');
$(element).find('.scorm-embedded').attr('src', path);
navigationClick = true;
});

// We only make calls to the get_value handler when absolutely required.
// These calls are synchronous and they can easily clog the scorm display.
var uncachedValues = [
Expand All @@ -173,20 +181,28 @@ function ScormXBlock(runtime, element, settings) {
];
var getValueUrl = runtime.handlerUrl(element, 'scorm_get_value');
var GetValue = function (cmi_element) {
if (uncachedValues.includes(cmi_element)) {
var response = $.ajax({
// Only make a call if navigation menu was not used
// Otherwise the synchronous calls are blocked by chromium on page unload
if (uncachedValues.includes(cmi_element) && !navigationClick) {
$.ajax({
type: "POST",
url: getValueUrl,
data: JSON.stringify({
'name': cmi_element
}),
async: false
async: false,
success: function (response) {
// Set to false to allow for other calls by the SCORM api
navigationClick = false;
return response.value;
}

});
response = JSON.parse(response.responseText);
return response.value;
} else if (cmi_element in settings.scorm_data) {
navigationClick = false;
return settings.scorm_data[cmi_element];
}
navigationClick = false;
return "";
};

Expand Down
Loading

0 comments on commit f7eed8c

Please sign in to comment.