diff --git a/branching_xblock/branching_xblock.py b/branching_xblock/branching_xblock.py index 972f42c..d3e82fb 100644 --- a/branching_xblock/branching_xblock.py +++ b/branching_xblock/branching_xblock.py @@ -2,17 +2,22 @@ import json import os import uuid +from collections import deque from typing import Any, Optional from web_fragments.fragment import Fragment from xblock.core import XBlock -from xblock.fields import Boolean, Dict, Float, List, Scope, String +from xblock.fields import Boolean, Dict, Integer, List, Scope, String from xblock.utils.resources import ResourceLoader from .compat import get_site_configuration_value, sanitize_html resource_loader = ResourceLoader(__name__) +DFS_STATE_UNVISITED = 0 +DFS_STATE_VISITING = 1 +DFS_STATE_VISITED = 2 + class BranchingXBlock(XBlock): """ @@ -71,12 +76,39 @@ class BranchingXBlock(XBlock): help="Allow learners to reset the activity" ) - max_score = Float( - default=100.0, + background_image_url = String( + default="", + scope=Scope.content, + help="Background image URL used for image nodes" + ) + + background_image_alt_text = String( + default="", + scope=Scope.content, + help="Alt text for the background image" + ) + + background_image_is_decorative = Boolean( + default=False, + scope=Scope.content, + help="Whether the background image is decorative" + ) + + max_score = Integer( + default=100, scope=Scope.content, help="Score awarded when scenario is completed (if scoring enabled)" ) + grade_ranges = List( + default=[ + {"label": "Fail", "start": 0, "end": 49}, + {"label": "Pass", "start": 50, "end": 100}, + ], + scope=Scope.content, + help="Grade range segments for end-of-activity grade report" + ) + current_node_id = String( scope=Scope.user_state, default=None, @@ -89,10 +121,16 @@ class BranchingXBlock(XBlock): help="Path history for undo functionality" ) - score = Float( + score_history = List( scope=Scope.user_state, - default=0.0, - help="Accumulated score (if scoring enabled)" + default=[], + help="Awarded points per selected choice, used for undo/reset" + ) + + choice_history = List( + scope=Scope.user_state, + default=[], + help="Selected choice records used for final report details" ) has_completed = Boolean( @@ -102,19 +140,384 @@ class BranchingXBlock(XBlock): ) has_custom_completion = True + _normalized_nodes_ref: Optional[dict[str, Any]] = None - def start_node(self): + def start_node(self) -> None: """ Set initial current_node_id if not set. """ if not self.current_node_id and self.scenario_data["start_node_id"]: self.current_node_id = self.scenario_data["start_node_id"] - def get_node(self, node_id): + def get_node(self, node_id: str) -> Optional[dict[str, Any]]: """ Get a node by its ID. """ - return self.scenario_data.get("nodes", {}).get(node_id) + nodes = self.scenario_data.get("nodes", {}) + if not isinstance(nodes, dict): + return None + + node = nodes.get(node_id) + if not isinstance(node, dict): + return None + + return node + + def _normalize_scenario_nodes(self) -> None: + """ + Normalize authoring payload shape for already-persisted scenario nodes. + + Studio can load node data that was saved before newer authoring fields + (e.g. `overlay_text`, `left_image_url`, `right_image_url`, normalized + `choices[*].score`) existed. Without this pass, the editor can receive + inconsistent node objects and fail to render/update reliably. + + What this does: + - Ensures `scenario_data["nodes"]` is a dict. + - Drops malformed non-dict nodes/choices. + - Fills missing node keys required by the current editor schema. + - Normalizes choice score shape for editor reads. + - Writes back only when changes are needed. + - Runs once per `nodes` object via `_normalized_nodes_ref`. + + Removal plan: + Once all persisted scenario data is guaranteed to follow the current + schema, this can be deleted. + """ + nodes = self.scenario_data.get("nodes", {}) + if nodes is self._normalized_nodes_ref: + return + + if not isinstance(nodes, dict): + self.scenario_data = {**self.scenario_data, "nodes": {}} + self._normalized_nodes_ref = self.scenario_data["nodes"] + return + + normalized_nodes: dict[str, dict[str, Any]] = {} + changed = False + for node_id, node in nodes.items(): + if not isinstance(node, dict): + changed = True + continue + + normalized_node, node_changed = self._normalize_scenario_node(node) + if node_changed: + changed = True + + normalized_nodes[node_id] = normalized_node + + if changed: + self.scenario_data = {**self.scenario_data, "nodes": normalized_nodes} + nodes = self.scenario_data.get("nodes", {}) + + self._normalized_nodes_ref = nodes + + def _normalize_scenario_node(self, node: dict[str, Any]) -> tuple[dict[str, Any], bool]: + """ + Normalize one stored node payload for backward-compatible authoring reads. + """ + normalized_node = dict(node) + changed = False + + if "overlay_text" not in normalized_node: + normalized_node["overlay_text"] = False + changed = True + + if "left_image_url" not in normalized_node: + media = normalized_node.get("media") or {} + normalized_node["left_image_url"] = ( + media.get("url", "") if (media.get("type") == "image") else "" + ) + changed = True + + if "right_image_url" not in normalized_node: + normalized_node["right_image_url"] = "" + changed = True + + raw_choices = normalized_node.get("choices", []) + choices_were_invalid = not isinstance(raw_choices, list) + if choices_were_invalid: + raw_choices = [] + changed = True + + normalized_choices = [] + for raw_choice in raw_choices: + normalized_choice, choice_changed = self._normalize_choice_score(raw_choice) + if normalized_choice is None: + changed = True + continue + normalized_choices.append(normalized_choice) + if choice_changed: + changed = True + + if choices_were_invalid or normalized_choices != raw_choices: + normalized_node["choices"] = normalized_choices + changed = True + + if normalized_node != node: + changed = True + + return normalized_node, changed + + def _normalize_choice_score(self, raw_choice: Any) -> tuple[Optional[dict[str, Any]], bool]: + """ + Normalize one choice object's score shape for authoring reads. + """ + if not isinstance(raw_choice, dict): + return None, True + + choice = dict(raw_choice) + changed = False + raw_score = choice.get("score") + + if raw_score is None: + if choice.get("score") != 0: + choice["score"] = 0 + changed = True + elif isinstance(raw_score, str): + stripped_score = raw_score.strip() + if not stripped_score: + choice["score"] = 0 + changed = True + else: + score = self._parse_choice_score(raw_score) + if score is not None and choice.get("score") != score: + choice["score"] = score + changed = True + else: + score = self._parse_choice_score(raw_score) + if score is not None and choice.get("score") != score: + choice["score"] = score + changed = True + + return choice, changed + + def _parse_choice_score(self, raw_score: Any) -> Optional[int]: + """ + Parse and validate choice score. + """ + if isinstance(raw_score, bool): + return None + if isinstance(raw_score, int): + score = raw_score + elif isinstance(raw_score, float): + if not raw_score.is_integer(): + return None + score = int(raw_score) + elif isinstance(raw_score, str): + stripped = raw_score.strip() + if not stripped or not stripped.lstrip('-').isdigit(): + return None + score = int(stripped) + else: + try: + score = int(raw_score) + except (TypeError, ValueError): + return None + return score if 0 <= score <= 100 else None + + def _find_cycle_node_ids(self, nodes: dict[str, dict[str, Any]]) -> set[str]: + """ + Return node IDs that participate in a directed cycle. + """ + state = {} + stack = [] + cycle_node_ids = set() + + def visit(node_id: str) -> None: + state[node_id] = DFS_STATE_VISITING + stack.append(node_id) + node = nodes.get(node_id, {}) + for choice in node.get("choices", []) or []: + target_node_id = choice.get("target_node_id") + if target_node_id not in nodes: + continue + target_state = state.get(target_node_id, DFS_STATE_UNVISITED) + if target_state == DFS_STATE_UNVISITED: + visit(target_node_id) + elif target_state == DFS_STATE_VISITING: + if target_node_id in stack: + cycle_start_index = stack.index(target_node_id) + cycle_node_ids.update(stack[cycle_start_index:]) + stack.pop() + state[node_id] = DFS_STATE_VISITED + + for node_id in nodes: + if state.get(node_id, DFS_STATE_UNVISITED) == DFS_STATE_UNVISITED: + visit(node_id) + + return cycle_node_ids + + def _compute_max_attainable_score( + self, + nodes: dict[str, dict[str, Any]], + start_node_id: Optional[str], + ) -> int: + """ + Compute the maximum total score over all reachable start-to-leaf paths. + """ + if not start_node_id or start_node_id not in nodes: + return 0 + + # Phase 1: collect only nodes reachable from the start node. + # This intentionally ignores orphan/disconnected nodes so they do not + # affect the grade denominator. + reachable = set() + pending = deque([start_node_id]) + while pending: + node_id = pending.popleft() + if node_id in reachable: + continue + reachable.add(node_id) + node = nodes.get(node_id, {}) + for choice in node.get("choices", []) or []: + target_node_id = choice.get("target_node_id") + if target_node_id in nodes and target_node_id not in reachable: + pending.append(target_node_id) + + # Phase 2: compute indegree on the reachable subgraph only. + # We use indegree + queue to process nodes in topological order, + # which is valid because cycles are blocked by save-time validation. + indegree = {node_id: 0 for node_id in reachable} + for node_id in reachable: + node = nodes.get(node_id, {}) + for choice in node.get("choices", []) or []: + target_node_id = choice.get("target_node_id") + if target_node_id in reachable: + indegree[target_node_id] += 1 + + topo_queue = deque([node_id for node_id, degree in indegree.items() if degree == 0]) + # best[node_id] stores the highest score found so far to reach node_id. + # We seed start at 0 and then relax outgoing edges. + best = {node_id: None for node_id in reachable} + best[start_node_id] = 0 + + while topo_queue: + node_id = topo_queue.popleft() + node_score = best[node_id] + node = nodes.get(node_id, {}) + + for choice in node.get("choices", []) or []: + target_node_id = choice.get("target_node_id") + if target_node_id not in reachable: + continue + + choice_score = choice.get("score", 0) + if node_score is not None: + # Dynamic-programming relaxation: + # if this path gives a higher total for target, keep it. + candidate = node_score + choice_score + prev_best = best[target_node_id] + best[target_node_id] = candidate if prev_best is None else max(prev_best, candidate) + + indegree[target_node_id] -= 1 + if indegree[target_node_id] == 0: + topo_queue.append(target_node_id) + + # Phase 3: evaluate only leaf nodes (no outgoing reachable targets). + # Max attainable score is defined as best start->leaf path sum. + leaf_scores = [] + for node_id in reachable: + node = nodes.get(node_id, {}) + outgoing_targets = [ + choice.get("target_node_id") + for choice in (node.get("choices", []) or []) + if choice.get("target_node_id") in reachable + ] + if not outgoing_targets and best[node_id] is not None: + leaf_scores.append(best[node_id]) + + # Defensive fallback: if no reachable leaf is detected, return the best + # finite score encountered (or 0). This keeps behavior predictable even + # for malformed graph edge-cases. + if not leaf_scores: + finite_scores = [score for score in best.values() if score is not None] + return max(finite_scores) if finite_scores else 0 + + return max(leaf_scores) + + def _validate_grade_ranges(self, grade_ranges: Any) -> Optional[str]: + """ + Validate contiguous grade range segments from 0 through 100. + """ + if not isinstance(grade_ranges, list) or len(grade_ranges) < 2: + return "Grade ranges must contain at least two contiguous segments." + + expected_start = 0 + for index, grade_range in enumerate(grade_ranges): + if not isinstance(grade_range, dict): + return f"Grade range {index + 1} is invalid." + + start = grade_range.get("start") + end = grade_range.get("end") + label = str(grade_range.get("label", "")).strip() + + if label == "": + return f"Grade range {index + 1} label is required." + if not isinstance(start, int) or not isinstance(end, int): + return f"Grade range {index + 1} bounds must be integers." + if start < 0 or end > 100: + return f"Grade range {index + 1} bounds must be between 0 and 100." + if end < start: + return f"Grade range {index + 1} has invalid bounds." + if start != expected_start: + return f"Grade range {index + 1} must start at {expected_start}." + + expected_start = end + 1 + + if grade_ranges[-1]["end"] != 100: + return "Final grade range must end at 100." + return None + + def _build_grade_report(self) -> dict[str, Any]: + """ + Compute learner-facing grade report data from current score state. + """ + safe_ranges = self.grade_ranges + + max_score = int(self.max_score or 0) + current_score = self._current_score() + percentage = 0.0 + if max_score > 0.0: + percentage = (current_score / max_score) * 100.0 + percentage = max(0.0, min(100.0, percentage)) + rounded_percentage = int(round(percentage)) + + matched_index = 0 + matched_range = safe_ranges[0] + for index, grade_range in enumerate(safe_ranges): + if grade_range["start"] <= rounded_percentage <= grade_range["end"]: + matched_index = index + matched_range = grade_range + break + + detailed_scores = [] + for entry in (self.choice_history or []): + choice_text = str(entry.get("choice_text", "")).strip() + if not choice_text: + continue + points = entry.get("awarded_points", 0) + if not isinstance(points, int): + points = 0 + detailed_scores.append({ + "choice_text": choice_text, + "awarded_points": points, + }) + + return { + "score": current_score, + "max_score": max_score, + "percentage": rounded_percentage, + "grade_label": matched_range.get("label", ""), + "is_pass_style": matched_index != 0, + "detailed_scores": detailed_scores, + } + + def _current_score(self) -> int: + """ + Compute the learner's accumulated score from score history. + """ + return sum(self.score_history) def get_current_node(self) -> Optional[dict[str, Any]]: """ @@ -122,38 +525,104 @@ def get_current_node(self) -> Optional[dict[str, Any]]: """ return self.get_node(self.current_node_id) if self.current_node_id else None - def is_end_node(self, node_id): + def is_end_node(self, node_id: str) -> bool: """ Check if node is a leaf node. """ node = self.get_node(node_id) return bool(node) and not node.get("choices") - def validate_scenario(self): + def validate_scenario(self, payload: dict[str, Any]) -> dict[str, Any]: """ - Check for common configuration errors. + Validate studio payload and return structured validation results. """ - errors = [] - nodes = self.scenario_data.get("nodes", {}) + validation_errors = { + "node_input_errors": {}, + "settings_field_errors": {}, + "global_errors": [], + "node_action_errors": {}, + } - if not nodes: - errors.append("At least one node is required") - return errors + raw_nodes = payload.get('nodes', []) + deleted_node_ids = set(payload.get('deleted_node_ids', [])) + background_image_url = (payload.get('background_image_url') or '').strip() + background_image_alt_text = (payload.get('background_image_alt_text') or '').strip() + background_image_is_decorative = bool(payload.get('background_image_is_decorative', False)) + grade_ranges = payload.get('grade_ranges', self.grade_ranges) + grade_ranges_error = self._validate_grade_ranges(grade_ranges) - # Check start node exists - start_id = self.scenario_data.get("start_node_id") - if start_id not in nodes: - errors.append("Start node ID does not exist") + if background_image_url and not background_image_is_decorative and not background_image_alt_text: + validation_errors["settings_field_errors"]["background_image_alt_text"] = ( + "Background image alt text is required unless the image is decorative." + ) + if grade_ranges_error: + validation_errors["settings_field_errors"]["grade_ranges"] = grade_ranges_error - # Check all choice targets exist - for node in nodes.values(): - for choice in node.get("choices", []): - if not self.get_node(choice["target_node_id"]): - errors.append(f"Invalid target {choice['target_node_id']} in node {node['id']}") + id_map, staged = self._build_staged_nodes(raw_nodes) + staged_node_ids = {node["id"] for node in staged} - return errors + resolved_deleted_node_ids = { + id_map.get(node_id, node_id) + for node_id in deleted_node_ids + } + node_number_by_id = { + node['id']: index + 1 + for index, node in enumerate(staged) + } + + self._validate_references( + staged=staged, + id_map=id_map, + resolved_deleted_node_ids=resolved_deleted_node_ids, + node_number_by_id=node_number_by_id, + staged_node_ids=staged_node_ids, + validation_errors=validation_errors, + ) + final = self._build_final_nodes(staged, resolved_deleted_node_ids, id_map, validation_errors) - def publish_grade(self): + if len(final) > 30: + self._add_global_error(validation_errors, "Too many nodes (max 30).") + + if not final: + self._add_global_error(validation_errors, "At least one node is required") + + client_id_by_node_id = { + node["id"]: node.get("client_id", node["id"]) + for node in final + } + + nodes_dict = { + node['id']: { + key: value + for key, value in node.items() + if key != "client_id" + } + for node in final + } + + cycle_node_ids = self._find_cycle_node_ids(nodes_dict) + if cycle_node_ids: + sorted_cycle_ids = sorted(cycle_node_ids) + for node_id in sorted_cycle_ids: + client_node_id = client_id_by_node_id.get(node_id, node_id) + self._add_node_error( + validation_errors, + node_client_id=client_node_id, + title="Circular path detected", + detail="This node links back through branching choices. Remove one link in the loop.", + ) + + return { + "validation_errors": validation_errors, + "nodes_dict": nodes_dict, + "start_node_id": final[0]['id'] if final else None, + "background_image_url": background_image_url, + "background_image_alt_text": background_image_alt_text, + "background_image_is_decorative": background_image_is_decorative, + "grade_ranges": grade_ranges, + } + + def publish_grade(self) -> None: """ Send score to gradebook. """ @@ -161,17 +630,17 @@ def publish_grade(self): self.runtime.publish( self, "grade", - {"value": self.score, "max_value": self.max_score} + {"value": self._current_score(), "max_value": self.max_score} ) - def resource_string(self, path): + def resource_string(self, path: str) -> str: """ Retrieve string contents for the file path. """ path = os.path.join('static', path) return resource_loader.load_unicode(path) - def student_view(self, context=None): + def student_view(self, context: Optional[dict[str, Any]] = None) -> Fragment: """ Create primary view of the BranchingXBlock, shown to students when viewing courses. """ @@ -182,10 +651,11 @@ def student_view(self, context=None): frag.initialize_js('BranchingXBlock') return frag - def studio_view(self, context=None): + def studio_view(self, context: Optional[dict[str, Any]] = None) -> Fragment: """ Studio editor view shown to course authors. """ + self._normalize_scenario_nodes() html = self.resource_string("html/branching_xblock_edit.html") frag = Fragment(html) @@ -193,7 +663,13 @@ def studio_view(self, context=None): frag.add_javascript_url( self.runtime.local_resource_url(self, 'public/js/vendor/handlebars.js') ) - for tpl in ['settings-panel', 'node-block', 'choice-row']: + for tpl in [ + 'settings-step', + 'nodes-step', + 'node-list-item', + 'node-editor', + 'choice-row', + ]: html = resource_loader.load_unicode(f'static/handlebars/{tpl}.handlebars') frag.add_javascript(f""" (function() {{ @@ -211,12 +687,12 @@ def studio_view(self, context=None): get_site_configuration_value("branching_xblock", "AUTHORING_HELP_HTML") or "" ) init_data = { - "nodes": self.scenario_data.get("nodes", {}), - "start_node_id": self.scenario_data.get("start_node_id"), + "nodes": self.scenario_data.get("nodes", []), "enable_undo": bool(self.enable_undo), "enable_scoring": bool(self.enable_scoring), "enable_reset_activity": bool(self.enable_reset_activity), "max_score": self.max_score, + "grade_ranges": self.grade_ranges, "display_name": self.display_name, "authoring_help_html": authoring_help_html, } @@ -224,30 +700,42 @@ def studio_view(self, context=None): frag.initialize_js('BranchingStudioEditor', init_data) return frag - def _get_state(self): + def _get_state(self) -> dict[str, Any]: + """ + Build the learner-facing runtime state payload. + """ + nodes = self.scenario_data.get("nodes", {}) + return { - "nodes": self.scenario_data.get("nodes", {}), + "nodes": nodes, "start_node_id": self.scenario_data.get("start_node_id"), "enable_undo": bool(self.enable_undo), "enable_scoring": bool(self.enable_scoring), "enable_reset_activity": bool(self.enable_reset_activity), + "background_image_url": self.background_image_url, + "background_image_alt_text": self.background_image_alt_text, + "background_image_is_decorative": bool(self.background_image_is_decorative), "max_score": self.max_score, + "grade_ranges": self.grade_ranges, "display_name": self.display_name, "current_node": self.get_current_node(), "history": list(self.history), + "score_history": list(self.score_history), + "choice_history": list(self.choice_history), "has_completed": bool(self.has_completed), - "score": self.score, + "score": self._current_score(), + "grade_report": self._build_grade_report(), } @XBlock.json_handler - def get_current_state(self, data, suffix=''): + def get_current_state(self, data: dict[str, Any], suffix: str = '') -> dict[str, Any]: """ Fetch current state of the XBlock. """ return self._get_state() @XBlock.json_handler - def select_choice(self, data, suffix=''): + def select_choice(self, data: dict[str, Any], suffix: str = '') -> dict[str, Any]: """ Handle choice selection. """ @@ -264,20 +752,36 @@ def select_choice(self, data, suffix=''): target_node = self.get_node(target_node_id) if not target_node: return {"success": False, "error": f"Target node {target_node_id} not found"} + + awarded_points = 0 + if self.enable_scoring: + raw_score = choice.get("score", 0) + if raw_score is None or (isinstance(raw_score, str) and not raw_score.strip()): + awarded_points = 0 + else: + awarded_points = self._parse_choice_score(raw_score) + if awarded_points is None: + return {"success": False, "error": "Invalid choice score"} + self.score_history.append(awarded_points) + self.choice_history.append({ + "source_node_id": self.current_node_id, + "choice_text": (choice.get("text") or "").strip(), + "awarded_points": awarded_points, + }) + if self.enable_undo: self.history.append(self.current_node_id) self.current_node_id = target_node_id if self.is_end_node(target_node_id): self.has_completed = True if self.enable_scoring: - self.score = self.max_score self.publish_grade() self.runtime.publish(self, "completion", {"completion": 1.0}) return {"success": True, **self._get_state()} @XBlock.json_handler - def undo_choice(self, data, suffix=''): + def undo_choice(self, data: dict[str, Any], suffix: str = '') -> dict[str, Any]: """ Handle undo choice. """ @@ -287,15 +791,18 @@ def undo_choice(self, data, suffix=''): prev_node_id = self.history.pop() self.current_node_id = prev_node_id - if self.has_completed and self.enable_scoring: - self.score = 0.0 + if self.enable_scoring: + if self.score_history: + self.score_history.pop() + if self.choice_history: + self.choice_history.pop() self.publish_grade() self.has_completed = False return {"success": True, **self._get_state()} @XBlock.json_handler - def reset_activity(self, data, suffix=''): + def reset_activity(self, data: dict[str, Any], suffix: str = '') -> dict[str, Any]: """ Reset learner state to the start node. """ @@ -306,108 +813,321 @@ def reset_activity(self, data, suffix=''): self.history = [] self.has_completed = False + self.score_history = [] + self.choice_history = [] if self.enable_scoring: - self.score = 0.0 self.publish_grade() self.start_node() self.runtime.publish(self, "completion", {"completion": 0.0}) return {"success": True, **self._get_state()} - @XBlock.json_handler - def studio_submit(self, data, suffix=''): + def _build_staged_nodes( + self, + raw_nodes: list[Any], + ) -> tuple[dict[str, str], list[dict[str, Any]]]: """ - Handle studio editor save. + Assign stable IDs and normalize raw studio node payloads. """ - payload = data - raw_nodes = payload.get('nodes', []) - - # 1) Assign real IDs and build id_map id_map = {} staged = [] for raw in raw_nodes: - old_id = raw.get('id', '') - # new ID if temp or missing - if old_id.startswith('temp-') or not old_id: - new_id = f"node-{uuid.uuid4().hex[:6]}" - else: - new_id = old_id - id_map[old_id] = new_id - # carry forward content & media, but keep raw choices for next step + if not isinstance(raw, dict): + continue + raw_old_id = raw.get('id') + old_id = raw_old_id.strip() if isinstance(raw_old_id, str) else '' + new_id = f"node-{uuid.uuid4().hex[:6]}" if old_id.startswith('temp-') or not old_id else old_id + if old_id: + id_map[old_id] = new_id staged.append({ 'id': new_id, + 'client_id': old_id or new_id, 'content': raw.get('content', ''), 'media': { 'type': raw.get('media', {}).get('type', ''), - 'url': raw.get('media', {}).get('url', '') + 'url': raw.get('media', {}).get('url', ''), }, - 'choices': raw.get('choices', []), - 'hint': raw.get('hint', ''), + 'left_image_url': raw.get('left_image_url', ''), + 'right_image_url': raw.get('right_image_url', ''), + 'choices': raw.get('choices', []) if isinstance(raw.get('choices'), list) else [], + 'hint': raw.get('hint', ''), + 'overlay_text': bool(raw.get('overlay_text', False)), 'transcript_url': raw.get('transcript_url', ''), }) + return id_map, staged + + def _has_validation_errors(self, validation_errors: dict[str, Any]) -> bool: + """Return True when any validation bucket contains an error.""" + return any(bool(value) for value in validation_errors.values()) + + def _add_global_error(self, validation_errors: dict[str, Any], message: str) -> None: + errors = validation_errors["global_errors"] + if message not in errors: + errors.append(message) + + def _add_node_field_error( + self, + validation_errors: dict[str, Any], + *, + node_client_id: str, + field_name: str, + message: str, + ) -> None: + """Attach field-level validation error to a node.""" + node_errors = validation_errors["node_input_errors"].setdefault(node_client_id, {}) + if field_name not in node_errors: + node_errors[field_name] = message + + def _add_node_indexed_error( + self, + validation_errors: dict[str, Any], + *, + node_client_id: str, + field_name: str, + index: int, + message: str, + ) -> None: + """Attach a per-index validation error (for repeated node fields like choices).""" + node_errors = validation_errors["node_input_errors"].setdefault(node_client_id, {}) + indexed_errors = node_errors.setdefault(field_name, {}) + key = str(index) + if key not in indexed_errors: + indexed_errors[key] = message + + def _add_node_error( + self, + validation_errors: dict[str, Any], + *, + node_client_id: str, + title: str, + detail: str, + ) -> None: + """Attach a node-level action error shown as a title/detail callout.""" + node_errors = validation_errors["node_action_errors"] + if node_client_id not in node_errors: + node_errors[node_client_id] = {"title": title, "detail": detail} + + def _error_response(self, validation_errors: dict[str, Any]) -> dict[str, Any]: + """Build a structured validation error response for studio save.""" + field_errors = { + key: value + for key, value in validation_errors.items() + if value + } + return { + "result": "error", + "message": "Validation errors", + "field_errors": field_errors, + } + + def _validate_references( + self, + *, + staged: list[dict[str, Any]], + id_map: dict[str, str], + resolved_deleted_node_ids: set[str], + node_number_by_id: dict[str, int], + staged_node_ids: set[str], + validation_errors: dict[str, Any], + ) -> None: + """ + Validate references to deleted/missing nodes and missing destinations. + """ + client_id_by_id = { + node["id"]: node.get("client_id", node["id"]) + for node in staged + } + for node in staged: + if node['id'] in resolved_deleted_node_ids: + continue + node_client_id = node.get("client_id", node["id"]) + source_node_number = node_number_by_id.get(node['id']) + for choice_index, raw_choice in enumerate(node['choices']): + if not isinstance(raw_choice, dict): + continue + choice_text = (raw_choice.get('text') or '').strip() + raw_target = (raw_choice.get('target_node_id') or '').strip() + if choice_text and not raw_target: + self._add_node_indexed_error( + validation_errors, + node_client_id=node_client_id, + field_name="choiceDestinationByIndex", + index=choice_index, + message="Required field", + ) + continue + if not raw_target: + continue - # 2) Remap choice targets & clean arrays + target_node_id = id_map.get(raw_target, raw_target) + target_node_client_id = client_id_by_id.get(target_node_id, target_node_id) + + if target_node_id not in staged_node_ids: + self._add_node_indexed_error( + validation_errors, + node_client_id=node_client_id, + field_name="choiceDestinationByIndex", + index=choice_index, + message="Selected destination is invalid.", + ) + continue + + if target_node_id not in resolved_deleted_node_ids: + continue + + target_node_number = node_number_by_id.get(target_node_id) + if source_node_number and target_node_number: + detail_message = ( + f"Node {target_node_number} is referenced by Node {source_node_number}." + ) + else: + detail_message = "This node is still referenced by another node in this scenario." + + self._add_node_indexed_error( + validation_errors, + node_client_id=node_client_id, + field_name="choiceDestinationByIndex", + index=choice_index, + message="Selected destination is pending deletion.", + ) + self._add_node_error( + validation_errors, + node_client_id=target_node_client_id, + title="You can't delete this node", + detail=detail_message, + ) + + def _build_final_nodes( + self, + staged: list[dict[str, Any]], + resolved_deleted_node_ids: set[str], + id_map: dict[str, str], + validation_errors: dict[str, Any], + ) -> list[dict[str, Any]]: + """ + Remap targets, drop blank nodes, and validate per-node fields. + """ final = [] for node in staged: - # filter out completely blank nodes + if node['id'] in resolved_deleted_node_ids: + continue + + node_client_id = node.get("client_id", node["id"]) has_content = bool(node['content'].strip()) - has_media = bool(node['media']['url'].strip()) + has_media = bool( + (node['media']['url'] or '').strip() + or (node.get('left_image_url', '') or '').strip() + or (node.get('right_image_url', '') or '').strip() + ) has_choices = any( - (c.get('text', '').strip() or c.get('target_node_id', '').strip()) - for c in node['choices'] + ( + isinstance(choice, dict) + and (choice.get('text', '').strip() or choice.get('target_node_id', '').strip()) + ) + for choice in node['choices'] ) if not (has_content or has_media or has_choices): continue - # remap and clean - cleaned = [] - for raw in node['choices']: - text = raw.get('text', '').strip() - targ = raw.get('target_node_id', '').strip() - # map through id_map if it was a temp ID - real_target = id_map.get(targ, targ) - if text or real_target: - cleaned.append({ - 'text': text, - 'target_node_id': real_target - }) + left_image_url = (node.get('left_image_url', '') or '').strip() + right_image_url = (node.get('right_image_url', '') or '').strip() + if node.get("media", {}).get("type") == "image" and not left_image_url and not right_image_url: + self._add_node_field_error( + validation_errors, + node_client_id=node_client_id, + field_name="left_image_url", + message="Please enter a valid URL", + ) + + cleaned_choices = [] + for choice_index, raw_choice in enumerate(node['choices']): + if not isinstance(raw_choice, dict): + self._add_node_indexed_error( + validation_errors, + node_client_id=node_client_id, + field_name="choiceScoreByIndex", + index=choice_index, + message="Score must be an integer between 0 and 100.", + ) + continue + text = raw_choice.get('text', '').strip() + target_node_id = raw_choice.get('target_node_id', '').strip() + if not (text or target_node_id): + continue + + raw_score = raw_choice.get('score', 0) + if raw_score is None or (isinstance(raw_score, str) and raw_score.strip() == ''): + score = 0 + else: + score = self._parse_choice_score(raw_score) + if score is None: + self._add_node_indexed_error( + validation_errors, + node_client_id=node_client_id, + field_name="choiceScoreByIndex", + index=choice_index, + message="Score must be an integer between 0 and 100.", + ) + continue + cleaned_choices.append({ + 'text': text, + 'target_node_id': id_map.get(target_node_id, target_node_id), + 'score': score, + }) final.append({ - 'id': node['id'], - 'type': 'start' if not final else 'normal', - 'content': node['content'], - 'media': node['media'], - 'choices': cleaned, + 'id': node['id'], + 'client_id': node_client_id, + 'type': 'start' if not final else 'normal', + 'content': node['content'], + 'media': node['media'], + 'choices': cleaned_choices, 'hint': node.get('hint', ''), + 'overlay_text': bool(node.get('overlay_text', False)), + 'left_image_url': left_image_url, + 'right_image_url': right_image_url, 'transcript_url': node.get('transcript_url', ''), }) + return final + + @XBlock.json_handler + def studio_submit(self, data: dict[str, Any], suffix: str = '') -> dict[str, Any]: + """ + Handle studio editor save. + """ + payload = data + validation_result = self.validate_scenario(payload) + validation_errors = validation_result["validation_errors"] + nodes_dict = validation_result["nodes_dict"] + start_node_id = validation_result["start_node_id"] + + if self._has_validation_errors(validation_errors): + return self._error_response(validation_errors) # 3) Persist scenario_data & settings - nodes_dict = {node['id']: node for node in final} self.scenario_data = { 'nodes': nodes_dict, - 'start_node_id': final[0]['id'] if final else None + 'start_node_id': start_node_id, } + self._normalized_nodes_ref = nodes_dict self.enable_undo = bool(payload.get('enable_undo', self.enable_undo)) self.enable_scoring = bool(payload.get('enable_scoring', self.enable_scoring)) self.enable_reset_activity = bool(payload.get('enable_reset_activity', self.enable_reset_activity)) - self.max_score = float(payload.get('max_score', self.max_score)) + self.max_score = self._compute_max_attainable_score( + nodes_dict, + start_node_id, + ) self.display_name = payload.get('display_name', self.display_name) + self.background_image_url = validation_result["background_image_url"] + self.background_image_alt_text = validation_result["background_image_alt_text"] + self.background_image_is_decorative = validation_result["background_image_is_decorative"] + self.grade_ranges = validation_result["grade_ranges"] - # 4) Validate & respond - errors = self.validate_scenario() - if errors: - return { - "result": "error", - "message": "Validation errors", - "field_errors": {"nodes_json": errors} - } return {"result": "success"} # TO-DO: change this to create the scenarios you'd like to see in the # workbench while developing your XBlock. - @staticmethod - def workbench_scenarios(): + def workbench_scenarios(self) -> list[tuple[str, str]]: """ Create canned scenario for display in the workbench. """ diff --git a/branching_xblock/static/css/branching_xblock.css b/branching_xblock/static/css/branching_xblock.css index b7bc2c9..3fb5bf2 100644 --- a/branching_xblock/static/css/branching_xblock.css +++ b/branching_xblock/static/css/branching_xblock.css @@ -26,6 +26,116 @@ align-self: center; } +.branching-scenario .bx-image-composite { + position: relative; + width: 100%; + max-width: 100%; + border-radius: 0.5rem; + overflow: hidden; + padding: 0.75rem; + box-sizing: border-box; +} + +.branching-scenario .bx-image-composite__bg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + display: block; + z-index: 0; +} + +.branching-scenario .bx-image-composite--bg-only { + padding: 0; + aspect-ratio: 16 / 9; +} + +.branching-scenario .bx-image-composite__fg { + position: relative; + z-index: 1; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; + align-items: center; +} + +.branching-scenario .bx-image-composite__img { + width: 100%; + height: auto; + display: block; + object-fit: cover; + border-radius: 0.5rem; +} + +.branching-scenario .bx-image-composite__overlay { + position: absolute; + z-index: 1; + left: 50%; + bottom: 1.25rem; + transform: translateX(-50%); + width: calc(100% - 3rem); + max-width: 90%; +} + +@media (max-width: 768px) { + .branching-scenario .bx-image-composite__fg { + grid-template-columns: 1fr; + } + + .branching-scenario .bx-image-composite__overlay { + position: static; + transform: none; + width: 100%; + max-width: 100%; + margin-top: 0.75rem; + } +} + +.branching-scenario .media-overlay { + position: relative; + width: 100%; + max-width: 100%; + display: inline-block; +} + +.branching-scenario .media-overlay img { + width: 100%; + height: auto; + display: block; + object-fit: cover; + border-radius: 0.5rem; +} + +.branching-scenario .media-overlay__text { + position: absolute; + left: 50%; + bottom: 1.25rem; + transform: translateX(-50%); + width: calc(100% - 3rem); + max-width: 90%; + background: rgba(8, 36, 73, 0.85); + color: #fff; + padding: 1rem 1.5rem; + border-radius: 0.5rem; + font-size: 1.05rem; + line-height: 1.5; + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.3); +} + +.branching-scenario .media-overlay__text p { + margin: 0; +} + +@media (max-width: 768px) { + .branching-scenario .media-overlay__text { + position: static; + transform: none; + width: 100%; + margin-top: 0.75rem; + } +} + .branching-scenario .node-media img, .branching-scenario .node-media video, .branching-scenario .node-media iframe { @@ -138,60 +248,122 @@ .branching-scenario .choices { + padding: 1.5em 1em; +} + +.branching-scenario .choices-heading { + font-size: 1.15rem; + font-weight: 600; + margin-bottom: 1rem; + color: #1f2a37; +} + +.branching-scenario .choices-form { display: flex; - flex-wrap: wrap; - gap: 0.75em; - padding: 1em; - justify-content: flex-start; + flex-direction: column; + gap: 1rem; } -/* inline mode keeps the natural width */ -.branching-scenario .choices-inline { - flex-wrap: wrap; +.branching-scenario .choices-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.branching-scenario .choice-option { + display: flex; + align-items: flex-start; + gap: 0.75rem; + border: 1px solid #d6d6d6; + border-radius: 8px; + padding: 0.9rem 1rem; + background-color: #fff; + transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease; + cursor: pointer; +} + +.branching-scenario .choice-option:hover, +.branching-scenario .choice-option:focus-within { + border-color: #3b82f6; + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2); +} + +.branching-scenario .choice-option.is-selected { + border-color: #0f62a1; + box-shadow: 0 0 0 2px rgba(15, 98, 161, 0.25); + background-color: #f0f7ff; +} + +.branching-scenario .choice-option__input { + margin-top: 0.2rem; + width: 1.1rem; + height: 1.1rem; +} + +.branching-scenario .choice-option__text { + flex: 1; + font-size: 1rem; + color: #1f2a37; +} + +.branching-scenario .choice-actions { + display: flex; justify-content: space-between; align-items: center; + gap: 1rem; + flex-wrap: wrap; +} + +.branching-scenario .choice-actions__secondary, +.branching-scenario .choice-actions__primary { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; +} + +.branching-scenario .choice-actions__primary { + margin-left: auto; + justify-content: flex-end; } -.branching-scenario .choices .choice-button { +.choice-submit-button { + border: none; + background-color: #0f62a1; color: #fff; - border: 1px solid #0e6aa6; border-radius: 6px; - padding: 0.65em 1.4em; - font-size: 1rem; - font-weight: 400; + padding: 11px 24px; + font-size: 16px; + font-weight: 600; cursor: pointer; - background-color: #0075b4; - background-image: none !important; - box-shadow: none !important; - display: inline-flex; - align-items: center; - justify-content: center; - flex-wrap: wrap; - gap: 0.35em; - line-height: 1.3; - white-space: normal; - text-align: center; - min-width: 6rem; - max-width: 100%; - flex: 0 1 18rem; + transition: background-color 0.2s ease; } -.branching-scenario .choices-inline .choice-button { - flex: 0 0 auto; +.choice-submit-button:disabled { + opacity: 0.5; + cursor: not-allowed; } -.branching-scenario .choices .choice-button:hover, -.branching-scenario .choices .choice-button:focus { - background-color: #005f94; - border-color: #0a5278; - text-decoration: none; +.choice-submit-button:not(:disabled):hover, +.choice-submit-button:not(:disabled):focus { + background-color: #0c4f83; } -.branching-scenario .action-row { - display: flex; - align-items: center; - justify-content: space-between; - width: 100%; +.show-report-button { + border: none; + background-color: #0f62a1; + color: #fff; + border-radius: 6px; + padding: 0.7rem 1.5rem; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: background-color 0.2s ease; +} + +.show-report-button:hover, +.show-report-button:focus { + background-color: #0c4f83; } .undo-button { @@ -204,14 +376,19 @@ color: #1f2a37; } +.undo-button.is-disabled { + opacity: 0.5; + cursor: not-allowed; +} + .reset-button { - border: 1px solid #c2410c; + border: 1px solid #9a3412; border-radius: 4px; padding: 0.6em 1.2em; font-size: 1em; cursor: pointer; - background: transparent; - color: #c2410c; + background: #ffffff; + color: #9a3412; } .reset-button:hover, @@ -222,7 +399,7 @@ } .reset-button:focus-visible { - outline: 2px solid #ea580c; + outline: 2px solid #9a3412; outline-offset: 2px; } @@ -230,3 +407,343 @@ padding: 1em 1em; font-size: small; } + +.grade-report { + margin: 1.5rem auto; + max-width: 1082px; + border-radius: 6px; + background: #001731; + color: #ffffff; + padding: 2rem; +} + +.grade-report__title { + margin: 0 0 0.25rem; + font-size: 1.375rem; + line-height: 1.27; + font-weight: 700; + color: #ffffff; +} + +.grade-report__subtitle { + margin: 0 0 1.5rem; + color: #ffffff; + font-size: 0.875rem; + line-height: 1.7; +} + +.grade-report__summary-card { + background: #ffffff; + border-radius: 14px; + padding: 1.75rem 1.9rem 1.65rem; + margin-bottom: 1.85rem; +} + +.grade-report__section-title { + margin: 0 0 1.2rem; + font-size: 1.375rem; + line-height: 1.27; + font-weight: 700; + color: #3b3a3a; + display: inline-flex; + align-items: center; + gap: 0.7rem; +} + +.grade-report__section-icon { + width: 38px; + height: 38px; + border-radius: 999px; + background: #d6e1df; + color: #3f4346; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.grade-report__section-icon .fa { + width: 22px; + height: 22px; + font-size: 22px; + line-height: 1; +} + +.grade-report__summary { + display: grid; + grid-template-columns: 1fr 1fr 260px; + gap: 1.6rem; + align-items: center; + min-height: 0; +} + +.grade-report__metric { + background: #ffffff; + color: #1f2a37; + border-radius: 12px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15), 0 1px 4px rgba(0, 0, 0, 0.15); + padding: 1.35rem 1.5rem; + min-height: 148px; + display: flex; + flex-direction: column; + justify-content: center; +} + +.grade-report__metric--score { + --report-score-color: #15803d; + background: #f2faf7; +} + +.grade-report__metric--max { + background: #f2f1f1; +} + +.grade-report__metric-label { + font-size: 0.875rem; + line-height: 1.35; + color: #454545; + margin-bottom: 0.65rem; + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + +.grade-report__metric-label-icon { + width: 0.95rem; + height: 0.95rem; + display: inline-flex; + align-items: center; + justify-content: center; + color: #7a7a7a; +} + +.grade-report__metric-label-icon .fa { + font-size: 0.95rem; + line-height: 1; +} + +.grade-report__metric-value { + font-size: 3.125rem; + line-height: 1.1; + font-weight: 700; +} + +.grade-report__metric--score .grade-report__metric-value { + color: var(--report-score-color); +} + +.grade-report__metric--score.is-fail { + --report-score-color: #b91c1c; + background: #fcf1f4; +} + +.grade-report__metric--score .grade-report__metric-label-icon { + color: var(--report-score-color); +} + +.grade-report__metric--max .grade-report__metric-value { + color: #4b5563; +} + +.grade-report__percent { + --report-percent-color: #15803d; + width: 210px; + height: 210px; + border-radius: 999px; + position: relative; + display: flex; + align-items: center; + justify-content: center; + color: var(--report-percent-color); + border: none; +} + +.grade-report__percent.is-fail { + --report-percent-color: #b91c1c; +} + +.grade-report__percent-ring { + width: 100%; + height: 100%; + transform: rotate(-90deg); +} + +.grade-report__percent-track, +.grade-report__percent-progress { + fill: none; + stroke-width: 7; +} + +.grade-report__percent-track { + stroke: #d6ddde; +} + +.grade-report__percent-progress { + stroke: var(--report-percent-color); + stroke-linecap: round; + transition: stroke-dashoffset 0.2s ease; +} + +.grade-report__percent-content { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.grade-report__percent-value { + font-size: 3.75rem; + line-height: 1; + font-weight: 700; + letter-spacing: -0.02em; +} + +.grade-report__percent-label { + font-size: 0.875rem; + line-height: 1.35; + margin-top: 0.35rem; + color: #3f3d3d; +} + +.grade-report__details { + background: #ffffff; + color: #1f2a37; + border-radius: 14px; + padding: 1.4rem 1.2rem 1.15rem; + margin-bottom: 1.6rem; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15), 0 1px 4px rgba(0, 0, 0, 0.15); +} + +.grade-report__details-title { + margin: 0 0 1.1rem; + font-size: 1.375rem; + line-height: 1.27; + font-weight: 700; + display: inline-flex; + align-items: center; + gap: 0.7rem; +} + +.grade-report__details-header, +.grade-report__details-row { + display: grid; + grid-template-columns: 1fr auto; + gap: 1rem; + align-items: start; +} + +.grade-report__details-header { + font-size: 0.875rem; + line-height: 1.35; + color: #000000; + font-weight: 700; + padding: 0.45rem 1rem; + background: #f2f0ef; + border-bottom: none; + border-radius: 2px; +} + +.grade-report__details-row { + font-size: 0.875rem; + line-height: 1.7; + padding: 0.62rem 1rem; + border-bottom: 1px solid #f2f0ef; +} + +.grade-report__details-row:last-child { + border-bottom: none; +} + +.grade-report__details-score { + font-weight: 500; + font-size: 0.75rem; + line-height: 1.67; + background: #e1dddb; + color: #000000; + border-radius: 6px; + min-width: 54px; + text-align: center; + padding: 0.15rem 0.7rem; +} + +.grade-report__details-row--empty { + display: block; + color: #6b7280; +} + +.grade-report [data-role="reset-activity-report"] { + margin-top: 0.35rem; +} + +@media (max-width: 600px) { + .branching-scenario .choice-actions { + flex-direction: column-reverse; + align-items: stretch; + } + + .branching-scenario .choice-actions__secondary, + .branching-scenario .choice-actions__primary { + width: 100%; + margin-left: 0; + } + + .undo-button, + .reset-button, + .choice-submit-button, + .show-report-button { + width: 100%; + text-align: center; + } + + .grade-report__summary { + grid-template-columns: 1fr; + } + + .grade-report__percent { + width: 150px; + height: 150px; + margin: 0 auto; + } + + .grade-report { + padding: 1.1rem; + } + + .grade-report__metric-value { + font-size: 2.3rem; + } + + .grade-report__percent-value { + font-size: 2.5rem; + } + + .grade-report__title { + font-size: 1.8rem; + } + + .grade-report__subtitle { + font-size: 0.95rem; + } + + .grade-report__section-title, + .grade-report__details-title { + font-size: 1.2rem; + } + + .grade-report__metric-label, + .grade-report__details-header, + .grade-report__details-row { + font-size: 1rem; + } + + .grade-report__details-score { + font-size: 0.85rem; + min-width: 36px; + } + + .grade-report__percent-label { + font-size: 1.1rem; + } + +} diff --git a/branching_xblock/static/css/studio_editor.css b/branching_xblock/static/css/studio_editor.css index c907de3..98c00c7 100644 --- a/branching_xblock/static/css/studio_editor.css +++ b/branching_xblock/static/css/studio_editor.css @@ -1,241 +1,649 @@ -.wrapper-comp-settings { - max-height: calc(100vh - 160px); - overflow-y: auto; - background: #fff; - border: 1px solid #ddd; - border-radius: 4px; - padding: 1em; +.branching-editor-step[hidden] { + display: none !important; } -.xblock-actions { - display: flex; - justify-content: space-between; - padding: 0.75em 1em; - border-top: 1px solid #e0e0e0; +.xblock-actions [hidden] { + display: none !important; +} + +.is-hidden { + display: none !important; +} + +.bx-wizard { + width: 100%; + max-width: none; + padding: 5rem; +} + +.bx-step-title { + margin: 0 0 12px; + font-size: 20px; + font-weight: 600; + line-height: 1.2; + color: #1f2933; +} + +.bx-settings-step .bx-step-title { + margin-bottom: 12px; + font-size: 19px; +} + +.bx-settings-step .bx-section-title { + margin: 12px 0; + font-size: 19px; + font-weight: 600; + line-height: 1.2; + color: #1f2933; + padding-top: 15px; +} + +.bx-section-title { + margin: 18px 0 10px; + font-size: 14px; + font-weight: 500; + line-height: 1.3; + color: #1f2933; +} + +.bx-help { + margin: 0 0 12px; + font-size: 14px; + line-height: 1.4; + color: #5b6570; +} + +.bx-field { + display: block; + margin-bottom: 12px; +} + +.bx-field__label { + margin-bottom: 4px; + font-size: 14px; + font-weight: 400; + line-height: 1.3; + color: #303842; +} + +.branching-editor-step .bx-input, +.branching-editor-step .bx-select, +.branching-editor-step .bx-textarea { + width: 100%; + max-width: 700px; + min-height: 40px; + border: 1px solid #c8cdd3; + border-radius: 0; background: #fff; - position: sticky; - bottom: 0; - z-index: 1; - box-shadow: 0 -2px 6px rgba(0, 0, 0, 0.05); + box-sizing: border-box; + padding: 8px 10px; + font-size: 14px; + font-weight: 400; + line-height: 1.3; + color: #1f2933; } -.xblock-actions .action-item { - margin: 0 0.25em; +.branching-editor-step .bx-input.is-error, +.branching-editor-step .bx-select.is-error, +.choice-row .choice-target.is-error { + border-color: #d23228; + box-shadow: inset 0 0 0 1px #d23228; } -.settings { - display: flex; - flex-direction: column; - gap: 0.75em; - margin-bottom: 1.5em; +.bx-field-error, +.choice-field-error { + margin-top: 4px; + font-size: 11px; + line-height: 1.2; + color: #d23228; } -.settings label { - font-size: 1em; - font-weight: bold; +.branching-editor-step .bx-textarea { + max-width: 700px; + min-height: 112px; + resize: vertical; } -.settings .display-name-row { +.bx-checkbox { display: flex; align-items: center; - gap: 0.75em; - font-size: 1em; - font-weight: 600; + gap: 8px; + margin: 10px 0; } -.settings .display-name-row input[name="display_name"] { - min-width: 220px; +.bx-checkbox input[type="checkbox"] { + width: 18px; + height: 18px; + margin: 0; } -.bx-authoring-help { - border: 1px solid #d0d0d0; - border-radius: 4px; +.branching-editor-step .bx-checkbox span { + font-size: 14px; + font-weight: 400; + line-height: 1.3; + color: #303842; +} + +.bx-checkbox--nested { + margin: 2px 0 8px; +} + +.bx-grade-range { + margin: 14px 0 18px; + max-width: 700px; + border: 1px solid #d8dde3; background: #f8f9fa; - padding: 1em; - align-self: flex-start; + padding: 10px 12px 12px; + border-radius: 4px; +} + +.bx-grade-range__title { + margin: 0 0 4px; + font-size: 14px; + font-weight: 600; + line-height: 1.3; + color: #303842; +} + +.bx-settings-step .bx-grade-range__title { + padding-top: 0; + margin-top: 0; +} + +.bx-grade-range__help { + margin: 0 0 6px; + font-size: 12px; + line-height: 1.35; + color: #5b6570; +} + +.bx-grade-range__track-wrap { width: 100%; - max-width: 800px; - box-sizing: content-box; } -.bx-authoring-help summary { +.bx-grade-range__track { + position: relative; + height: 36px; + border: 1px solid #a9b4bf; + border-radius: 2px; + overflow: hidden; + background: #fff; + margin-top: 6px; +} + +.bx-grade-range__segment { + position: absolute; + top: 0; + bottom: 0; + box-sizing: border-box; display: flex; - align-items: center; - gap: 0.5em; + flex-direction: column; + justify-content: center; + align-items: flex-end; + padding-right: 10px; + gap: 2px; + overflow: hidden; +} + +.bx-grade-range__segment--fail { + background: #f4c7c3; +} + +.bx-grade-range__segment--pass { + background: #c3da8a; +} + +.bx-grade-range__segment-label { + font-size: 10px; font-weight: 600; - cursor: pointer; - user-select: none; - padding: 0.25em 0; - color: #1d4ed8; + line-height: 1; + color: #3f4750; } -.bx-authoring-help summary::-webkit-details-marker { - display: none; +.bx-grade-range__segment-range { + font-size: 9px; + font-weight: 500; + line-height: 1; + color: #4e5965; } -.bx-authoring-help summary::after { - content: "▾"; - margin-left: auto; - transform: rotate(0deg); - transition: transform 150ms ease-in-out; +.bx-grade-range__ticks { + display: flex; + justify-content: space-between; + margin-top: 8px; } -.bx-authoring-help[open] summary::after { - transform: rotate(180deg); +.bx-grade-range__tick { + font-size: 10px; + line-height: 1; + color: #7a8591; } -.bx-authoring-help summary:hover { - color: #1d4ed8; +.bx-grade-range__handle { + position: absolute; + top: 50%; + width: 1px; + height: 36px; + transform: translate(-50%, -50%); + border: 0; + border-radius: 0; + background: #5d6671; + cursor: ew-resize; + padding: 0; + z-index: 2; + box-shadow: none; } -.bx-authoring-help summary:focus-visible { - outline: 2px solid #1d4ed8; - outline-offset: 2px; +.bx-grade-range__handle:focus-visible { + outline: 2px solid #0a6fb5; + outline-offset: 1px; } -.bx-authoring-help-body { - margin-top: 0.5em; - font-style: normal; +.bx-nodes-step { + display: flex; + gap: 14px; + min-height: 520px; } -.node-block { - max-width: 800px; - background: #f9f9f9; - border: 1px solid #ccc; - border-radius: 4px; - padding: 1em; - margin-bottom: 1em; +.bx-nodes-sidebar { + width: 240px; + flex: none; + border-right: 1px solid #e5e7eb; + padding-right: 14px; } -.node-block label { - display: block; - margin-bottom: 1em; +.bx-node-limit { + margin: 8px 0 12px; + font-size: 13px; + color: #6b7280; } -.node-header .btn-delete-node { - background-color: #dc3545; - color: #fff; - border: none; - border-radius: 4px; - padding: 0.4em 0.8em; - font-size: 0.9em; - cursor: pointer; +.bx-node-list { + display: flex; + flex-direction: column; + gap: 8px; } -.node-header { +.bx-node-list-item { display: flex; + align-items: center; justify-content: space-between; + min-height: 40px; + padding: 0 10px; + border: 1px solid transparent; + border-radius: 0; +} + +.bx-node-list-item.is-selected { + background: #f3f4f6; + border-color: #f3f4f6; +} + +.bx-node-list-item.is-pending-delete { + background: #f9fafb; + border-color: #e5e7eb; +} + +.bx-node-list-item.is-pending-delete .bx-node-list-item__select { + color: #6b7280; + text-decoration: line-through; +} + +.bx-node-list-item__select { + flex: 1; + border: 0; + background: transparent; + padding: 0; + text-align: left; + cursor: pointer; + font-size: 13px; + line-height: 1.3; + color: #1f2933; +} + +.bx-node-list-item__meta { + font-size: 11px; + color: #d23228; +} + +.bx-node-list-item__error { + display: inline-flex; align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border-radius: 50%; + margin-right: 6px; + font-size: 10px; + font-weight: 700; + line-height: 1; + color: #fff; + background: #dc3545; } -.node-block .node-content { - width: 100%; - min-height: 8em; - padding: 0.5em; - box-sizing: border-box; - resize: vertical; +.bx-node-list-item__delete { + border: 0; + background: transparent; + cursor: pointer; + font-size: 14px; + line-height: 1; + color: #6b7280; + opacity: 0.9; } -.node-block .media-type { - display: inline-block; - width: auto; - max-width: 200px; - margin-top: 0.25em; +.bx-node-list-item__delete .fa { + font-size: 14px; + line-height: 1; } -.node-block .media-url { - display: block; - margin-top: 0.5em; - width: 100%; - box-sizing: border-box; +.bx-node-list-item__delete:hover { + opacity: 1; } -.node-block .transcript-field { - margin-top: 0.5em; +.bx-node-list-item__delete.is-restore { + color: #303842; } -.node-block .transcript-label { +.bx-node-editor-header { display: flex; - flex-direction: column; - gap: 0.35em; + align-items: center; + gap: 8px; + margin-bottom: 12px; } -.node-block .transcript-url { - width: 100%; - box-sizing: border-box; +.bx-node-editor-header .bx-step-title { + margin-bottom: 0; } -.choices-container { - margin-top: 1em; +.bx-node-error-banner { + display: flex; + align-items: flex-start; + gap: 8px; + margin: 0 0 12px; + padding: 10px 12px; + border: 1px solid #f5c2c7; + border-radius: 4px; + background: #f8d7da; + color: #842029; + max-width: 675px; } -.choices-container::before { - content: "Choices"; - display: block; - font-weight: bold; - margin-bottom: 0.5em; +.bx-node-error-banner__icon { + flex: none; + margin-top: 1px; + font-size: 14px; + line-height: 1.2; + color: #d23228; } -.choice-row { - display: flex; +.bx-node-error-banner__content { + min-width: 0; +} + +.bx-node-error-banner__title { + font-size: 13px; + font-weight: 600; + line-height: 1.4; +} + +.bx-node-error-banner__detail { + margin-top: 2px; + font-size: 13px; + font-weight: 400; + line-height: 1.35; +} + +.bx-pending-delete-badge { + display: inline-flex; align-items: center; - gap: 0.5em; - margin-bottom: 0.75em; - margin-top: 0.75em; + min-height: 18px; + border-radius: 999px; + background: #dc3545; + color: #fff; + padding: 1px 8px; + font-size: 11px; + font-weight: 600; + line-height: 1.2; } -.choice-row input.choice-text { +.bx-nodes-main { flex: 1; + padding-left: 0; } -.choice-row select.choice-target { +.bx-node-editor-inner .bx-help { + margin-top: 4px; +} + +.bx-choices .choices-container { + margin-top: 8px; +} + +.bx-choices-help { + margin: 0 0 8px; + font-size: 12px; + line-height: 1.3; + color: #6b7280; +} + +.choice-row { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: 10px; + margin: 0 0 10px; +} + +.choice-col { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.choice-col--text { + flex: 1 1 360px; + min-width: 260px; + max-width: 420px; +} + +.choice-col--score { + flex: none; + width: 88px; +} + +.choice-col--target { flex: none; - width: 200px; + width: 180px; +} + +.choice-col__label { + font-size: 12px; + line-height: 1.2; + color: #303842; +} + +.choice-row input.choice-text, +.choice-row input.choice-score, +.choice-row select.choice-target { + width: 100%; + min-height: 36px; + height: 36px; + box-sizing: border-box; + border: 1px solid #c8cdd3; + border-radius: 4px; + padding: 7px 10px; + font-size: 14px; + font-weight: 400; + line-height: 1.3; + color: #303842; + background: #fff; } .choice-row .btn-delete-choice { - background-color: #dc3545; - color: #fff; - border: none; + width: 36px; + min-width: 36px; + min-height: 36px; + border: 0; border-radius: 4px; - padding: 0.3em 0.6em; - font-size: 0.9em; - cursor: pointer; - flex: none; + background: #dc3545; + color: #fff; + font-size: 18px; line-height: 1; + cursor: pointer; + margin-bottom: 0; + margin-top: 20px; } .choice-row .btn-delete-choice:hover { - opacity: 0.85; + opacity: 0.9; +} + +.bx-choices .bx-btn--secondary { + min-height: 36px; + border-radius: 4px; + padding: 7px 12px; + font-size: 13px; + line-height: 1.3; + margin: 4px 0 0; +} + +.overlay-text-control { + margin-bottom: 8px; +} + +.overlay-text-toggle { + display: flex; + align-items: center; + gap: 6px; + font-size: 14px; + font-weight: 400; + line-height: 1.3; + color: #303842; +} + +.overlay-text-help { + margin: 4px 0 0; + font-size: 12px; + line-height: 1.3; + color: #6b7280; +} + +.bx-btn { + border: 1px solid #006fb9; + border-radius: 6px; + padding: 6px 10px; + font-size: 14px; + font-weight: 500; + line-height: 1.2; + cursor: pointer; } -.btn-add-choice, -.btn-add-node { - display: inline-block; - background-color: #007bff; +.bx-btn--primary { + background: #006fb9; + border-color: #006fb9; color: #fff; +} + +.bx-btn--primary:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.bx-btn--secondary { + background: #37a94a; + border-color: #37a94a; + color: #fff; + margin: 8px 0 6px; +} + +.xblock-actions { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 14px; + padding: 10px 14px; + background: #fff; + position: sticky; + bottom: 0; + z-index: 20; + border-top: 1px solid #e5e7eb; + box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06); +} + +.xblock-actions__warning { + margin-left: auto; + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + line-height: 1.2; + color: #7c5e00; + padding: 0; +} + +.xblock-actions__warning::before { + content: "\f071"; + font-family: FontAwesome; + font-size: 11px; + line-height: 1; + color: #c08a00; +} + +.xblock-actions__error { + margin-left: auto; + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + line-height: 1.2; + color: #d23228; +} + +.xblock-actions__error::before { + content: "\f06a"; + font-family: FontAwesome; + font-size: 11px; + line-height: 1; + color: #d23228; +} + +.xblock-actions .action-primary { + min-height: 36px; border: none; - border-radius: 4px; - padding: 0.6em 1.2em; - font-size: 1em; + border-radius: 6px; + background: #0f62a1; + color: #fff; + padding: 0.7rem 1.5rem; + font-size: 1rem; + font-weight: 600; + line-height: 1.2; cursor: pointer; - margin-top: 0.75em; - margin-bottom: 0.75em; + transition: background-color 0.2s ease; } -.btn-add-choice { - background-color: #28a745; +.xblock-actions .action-primary:disabled { + opacity: 0.5; + cursor: not-allowed; } -.btn-add-choice:hover, -.btn-add-node:hover, -.choice-row .btn-delete-choice:hover { - opacity: 0.85; +.xblock-actions .action-primary:not(:disabled):hover, +.xblock-actions .action-primary:not(:disabled):focus { + background-color: #0c4f83; +} + +.xblock-actions .action-secondary { + min-height: 36px; + border: 0; + background: transparent; + color: #0a6fb5; + padding: 0; + text-decoration: none; + font-size: 14px; + font-weight: 500; + line-height: 1.2; } -.editor-note { - font-size: 0.75em; - color: #555; - margin-top: 0.25em; - font-style: italic; +.xblock-actions .action-secondary:hover { + text-decoration: underline; } diff --git a/branching_xblock/static/handlebars/choice-row.handlebars b/branching_xblock/static/handlebars/choice-row.handlebars index 81ed2cf..542b430 100644 --- a/branching_xblock/static/handlebars/choice-row.handlebars +++ b/branching_xblock/static/handlebars/choice-row.handlebars @@ -1,9 +1,35 @@
- - - +
+ + +
+
+ + + {{#if score_error}} +
{{score_error}}
+ {{/if}} +
+
+ + + {{#if destination_error}} +
{{destination_error}}
+ {{/if}} +
+
diff --git a/branching_xblock/static/handlebars/node-block.handlebars b/branching_xblock/static/handlebars/node-block.handlebars index 7e74891..9652af2 100644 --- a/branching_xblock/static/handlebars/node-block.handlebars +++ b/branching_xblock/static/handlebars/node-block.handlebars @@ -22,6 +22,13 @@ +
+ +

If left unchecked, text will appear outside the image.

+
diff --git a/branching_xblock/static/handlebars/node-editor.handlebars b/branching_xblock/static/handlebars/node-editor.handlebars new file mode 100644 index 0000000..6900328 --- /dev/null +++ b/branching_xblock/static/handlebars/node-editor.handlebars @@ -0,0 +1,85 @@ +
+ {{#if node_error_detail}} + + {{/if}} + +
+

Node {{number}}

+ {{#if is_pending_delete}} + Pending deletion + {{/if}} +
+ + + + + +
+ + +
+ + + + + +
+ +

If left unchecked, text will appear outside the image.

+
+ + + + + +
+

Choices

+
When a learner selects a choice, the score assigned to that choice is added to the total score.
+
+ +
+
diff --git a/branching_xblock/static/handlebars/node-list-item.handlebars b/branching_xblock/static/handlebars/node-list-item.handlebars new file mode 100644 index 0000000..4ec8d91 --- /dev/null +++ b/branching_xblock/static/handlebars/node-list-item.handlebars @@ -0,0 +1,21 @@ +
+ + {{#if has_errors}} + ! + {{/if}} + +
diff --git a/branching_xblock/static/handlebars/nodes-step.handlebars b/branching_xblock/static/handlebars/nodes-step.handlebars new file mode 100644 index 0000000..d17a56e --- /dev/null +++ b/branching_xblock/static/handlebars/nodes-step.handlebars @@ -0,0 +1,12 @@ +
+
+ +
Max 30 nodes
+
+
+ +
+
+
+
+ diff --git a/branching_xblock/static/handlebars/settings-step.handlebars b/branching_xblock/static/handlebars/settings-step.handlebars new file mode 100644 index 0000000..d604218 --- /dev/null +++ b/branching_xblock/static/handlebars/settings-step.handlebars @@ -0,0 +1,70 @@ +
+

Settings

+ + + + + + + + + +
+

Specify Grade Range

+

Adjust the scale to set percentage ranges for each grade.

+
+ {{#if grade_ranges_error}} +
{{grade_ranges_error}}
+ {{/if}} +
+ +

Background image

+

+ This image will appear in the background of any nodes with “Image” as the media type. +

+ + + +
+
Alt text
+ + + {{#if background_image_alt_text_error}} +
{{background_image_alt_text_error}}
+ {{/if}} +
+
diff --git a/branching_xblock/static/html/branching_xblock.html b/branching_xblock/static/html/branching_xblock.html index 99a6728..c2585ca 100644 --- a/branching_xblock/static/html/branching_xblock.html +++ b/branching_xblock/static/html/branching_xblock.html @@ -11,17 +11,87 @@
-
- -
- - +
+

Choose Next Step:

+
+
+
+
+ + +
+
+ + +
+
+
+
diff --git a/branching_xblock/static/html/branching_xblock_edit.html b/branching_xblock/static/html/branching_xblock_edit.html index 1b77928..1edd3d2 100644 --- a/branching_xblock/static/html/branching_xblock_edit.html +++ b/branching_xblock/static/html/branching_xblock_edit.html @@ -1,12 +1,16 @@
- -
+
+
- - + + + + + +
diff --git a/branching_xblock/static/js/src/branching_xblock.js b/branching_xblock/static/js/src/branching_xblock.js index dfe108d..c4127a8 100644 --- a/branching_xblock/static/js/src/branching_xblock.js +++ b/branching_xblock/static/js/src/branching_xblock.js @@ -3,6 +3,9 @@ function BranchingXBlock(runtime, element) { let currentHintNodeId = null; let isHintVisible = false; + let selectedChoiceIndex = null; + let isReportVisible = false; + let currentState = null; const MEDIA_FILE_REGEX = /\.(mp4|webm|ogg|mp3|wav)(\?|#|$)/i; @@ -77,11 +80,67 @@ function BranchingXBlock(runtime, element) { const $summary = $details.find('[data-role="hint-summary"]'); $details.prop('open', isHintVisible); if ($summary.length) { - $summary.text(isHintVisible ? 'Hide hint' : 'Show hint'); + $summary.text(isHintVisible ? 'Hide' : 'Show hint'); } } + function renderGradeReport(reportData, showResetInReport) { + const $report = $el.find('[data-role="grade-report"]'); + const score = Number(reportData?.score || 0); + const maxScore = Number(reportData?.max_score || 0); + const percentage = Number(reportData?.percentage || 0); + const gradeLabel = String(reportData?.grade_label || '').trim(); + const isPassStyle = Boolean(reportData?.is_pass_style); + const details = Array.isArray(reportData?.detailed_scores) ? reportData.detailed_scores : []; + + $report.find('[data-role="report-score"]').text(String(Math.round(score))); + $report.find('[data-role="report-max-score"]').text(String(Math.round(maxScore))); + $report.find('[data-role="report-percent"]').text(`${percentage}%`); + $report.find('[data-role="report-grade-label"]').text(gradeLabel); + const boundedPercentage = Math.max(0, Math.min(100, percentage)); + const $percentPill = $report.find('[data-role="report-percent-pill"]'); + $percentPill + .toggleClass('is-fail', !isPassStyle); + const $scoreMetric = $report.find('.grade-report__metric--score'); + $scoreMetric + .toggleClass('is-fail', !isPassStyle); + const $scoreIcon = $report.find('[data-role="report-score-icon"]'); + $scoreIcon + .toggleClass('fa-trophy', isPassStyle) + .toggleClass('fa-exclamation-triangle', !isPassStyle); + const $percentCircle = $report.find('[data-role="report-percent-circle"]'); + if ($percentCircle.length) { + const radius = Number($percentCircle.attr('r')) || 48; + const circumference = 2 * Math.PI * radius; + const offset = circumference * (1 - (boundedPercentage / 100)); + $percentCircle.css({ + strokeDasharray: circumference, + strokeDashoffset: offset, + }); + } + $report.find('[data-role="reset-activity-report"]').toggle(showResetInReport); + + const $details = $report.find('[data-role="report-details"]').empty(); + if (!details.length) { + $details.append( + $('
') + .text('No scored selections were recorded for this attempt.') + ); + return; + } + + details.forEach((entry) => { + const text = String(entry.choice_text || '').trim(); + const points = Number(entry.awarded_points || 0); + const $row = $('
'); + $row.append($('').text(text || 'Untitled choice')); + $row.append($('').text(String(points))); + $details.append($row); + }); + } + function updateView(state) { + currentState = state; const node = state.current_node || state.nodes[state.start_node_id] || {}; // Active node @@ -89,6 +148,16 @@ function BranchingXBlock(runtime, element) { const media = (node && node.media) || {}; const mediaUrl = media.url || ''; + const contentHtml = (node && node.content) || ''; + const overlayEnabled = Boolean(node?.overlay_text && media.type === 'image'); + const backgroundImageUrl = state?.background_image_url || ''; + const backgroundImageAltText = state?.background_image_alt_text || ''; + const backgroundImageIsDecorative = Boolean(state?.background_image_is_decorative); + const leftImageUrl = (node?.left_image_url !== undefined && node?.left_image_url !== null) + ? node.left_image_url + : (mediaUrl || ''); + const rightImageUrl = node?.right_image_url || ''; + const $media = $el.find('[data-role="media"]'); const $transcript = $el.find('[data-role="transcript"]'); const setTranscript = (href) => { @@ -99,7 +168,52 @@ function BranchingXBlock(runtime, element) { } }; if (media.type === 'image') { - $media.html(``); + const $composite = $('
').addClass('bx-image-composite'); + if (backgroundImageUrl) { + const $backgroundImage = $('') + .addClass('bx-image-composite__bg') + .attr('src', backgroundImageUrl); + if (backgroundImageIsDecorative) { + $backgroundImage.attr('alt', '').attr('aria-hidden', 'true'); + } else { + $backgroundImage.attr('alt', backgroundImageAltText); + } + $composite.append($backgroundImage); + } + + const hasForeground = Boolean(leftImageUrl || rightImageUrl); + if (!hasForeground && backgroundImageUrl) { + $composite.addClass('bx-image-composite--bg-only'); + } else if (hasForeground) { + const $fg = $('
').addClass('bx-image-composite__fg'); + if (leftImageUrl) { + $fg.append( + $('') + .addClass('bx-image-composite__img bx-image-composite__img--left') + .attr('src', leftImageUrl) + .attr('alt', '') + ); + } + if (rightImageUrl) { + $fg.append( + $('') + .addClass('bx-image-composite__img bx-image-composite__img--right') + .attr('src', rightImageUrl) + .attr('alt', '') + ); + } + $composite.append($fg); + } + + if (overlayEnabled) { + $composite.append( + $('
') + .addClass('media-overlay__text bx-image-composite__overlay') + .html(contentHtml) + ); + } + + $media.empty().append($composite); setTranscript(null); } else if (media.type === 'audio') { $media.html(`