diff --git a/lti_consumer/__init__.py b/lti_consumer/__init__.py
index 0e1c781d..f676dd3f 100644
--- a/lti_consumer/__init__.py
+++ b/lti_consumer/__init__.py
@@ -4,4 +4,4 @@
from .apps import LTIConsumerApp
from .lti_xblock import LtiConsumerXBlock
-__version__ = '11.1.0'
+__version__ = '11.2.0'
diff --git a/lti_consumer/lti_xblock.py b/lti_consumer/lti_xblock.py
index b7d62dc0..61871ce6 100644
--- a/lti_consumer/lti_xblock.py
+++ b/lti_consumer/lti_xblock.py
@@ -144,14 +144,14 @@ def valid_config_type_values(block):
valid value options, depending on the state of the appropriate toggle.
"""
values = [
- {"display_name": _("Configuration on block"), "value": "new"}
+ {"display_name": _("New"), "value": "new"}
]
if database_config_enabled(block.scope_ids.usage_id.context_key):
- values.append({"display_name": _("Database Configuration"), "value": "database"})
+ values.append({"display_name": _("Database"), "value": "database"})
if external_config_filter_enabled(block.scope_ids.usage_id.context_key):
- values.append({"display_name": _("Reusable Configuration"), "value": "external"})
+ values.append({"display_name": _("Existing"), "value": "external"})
return values
@@ -261,18 +261,15 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock):
display_name = String(
display_name=_("Display Name"),
help=_(
- "Enter the name that students see for this component. "
- "Analytics reports may also use the display name to identify this component."
+ "Enter the name learners see for this component. This name may also appear in reports."
),
scope=Scope.settings,
default=_("LTI Consumer"),
)
description = String(
- display_name=_("LTI Application Information"),
+ display_name=_("Data Sharing Notice"),
help=_(
- "Enter a description of the third party application. "
- "If requesting username and/or email, use this text box to inform users "
- "why their username and/or email will be forwarded to a third party application."
+ "Enter a short notice about the tool. Use this field to explain why learner data may be shared with it."
),
default="",
scope=Scope.settings
@@ -283,9 +280,8 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock):
values_provider=valid_config_type_values,
default="new",
help=_(
- "Select 'Configuration on block' to configure a new LTI Tool. "
- "If the support staff provided you with a pre-configured LTI reusable Tool ID, select"
- "'Reusable Configuration' and enter it in the text field below."
+ "Select 'New' to configure a new LTI Tool. If your admin has provided you with ID of an existing "
+ "reusable tool configuration, select 'Existing' and enter it in the text field on the right."
)
)
@@ -298,16 +294,14 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock):
],
default="lti_1p1",
help=_(
- "Select the LTI version that your tool supports."
- "
The XBlock LTI Consumer fully supports LTI 1.1.1, "
- "LTI 1.3 and LTI Advantage features."
+ "Select the LTI version supported by your tool."
),
)
external_config = String(
- display_name=_("LTI Reusable Configuration ID"),
+ display_name=_("LTI Reusability ID"),
scope=Scope.settings,
- help=_("Enter the reusable LTI external configuration ID provided by the support staff."),
+ help=_("Enter the ID of an existing reusable tool configuration."),
)
# LTI 1.3 fields
@@ -319,32 +313,27 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock):
)
lti_1p3_launch_url = String(
- display_name=_("Tool Launch URL"),
+ display_name=_("Launch URL"),
default='',
scope=Scope.settings,
help=_(
- "Enter the LTI 1.3 Tool Launch URL. "
- "
This is the URL the LMS will use to launch the LTI Tool."
+ "Enter the launch URL for this tool. For LTI 1.3, this is the URL Open edX uses to launch the tool."
),
)
lti_1p3_oidc_url = String(
- display_name=_("Tool Initiate Login URL"),
+ display_name=_("Tool Initiate Login URL (OIDC)"),
default='',
scope=Scope.settings,
help=_(
- "Enter the LTI 1.3 Tool OIDC Authorization url (can also be called login or login initiation URL)."
- "
This is the URL the LMS will use to start a LTI authorization "
- "prior to doing the launch request."
+ "Enter the tool’s OIDC login initiation URL. Open edX uses this URL to start the LTI 1.3 login flow."
),
)
lti_1p3_redirect_uris = List(
display_name=_("Registered Redirect URIs"),
help=_(
- "Valid urls the Tool may request us to redirect the id token to. The redirect uris "
- "are often the same as the launch url/deep linking url so if this field is "
- "empty, it will use them as the default. If you need to use different redirect "
- "uri's, enter them here. If you use this field you must enter all valid redirect "
- "uri's the tool may request."
+ "Enter the redirect URIs this tool is allowed to use during login "
+ 'e.g. ["https://tool.com", "https://tool_deeplink.com"]. Leave this blank to use '
+ "the launch URL and deep linking URL by default."
),
scope=Scope.settings
)
@@ -358,7 +347,7 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock):
],
default="public_key",
help=_(
- "Select how the tool's public key information will be specified."
+ "Choose how Open edX will get the tool’s public key for validating signed messages."
),
)
lti_1p3_tool_keyset_url = String(
@@ -366,16 +355,8 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock):
default='',
scope=Scope.settings,
help=_(
- "Enter the LTI 1.3 Tool's JWK keysets URL."
- "
This link should retrieve a JSON file containing"
- " public keys and signature algorithm information, so"
- " that the LMS can check if the messages and launch"
- " requests received have the signature from the tool."
- "
This is not required when doing LTI 1.3 Launches"
- " without LTI Advantage nor Basic Outcomes requests."
- "
Changing the public key or keyset URL will cause the client ID, block keyset URL "
- "and access token URL to be regenerated if they are shared between blocks. "
- "Please check and update them in the LTI tool settings if necessary."
+ "Enter the URL of the tool’s JWK keyset. Open edX uses this URL to retrieve the "
+ "public keys needed to validate signed messages."
),
)
lti_1p3_tool_public_key = String(
@@ -384,21 +365,17 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock):
default='',
scope=Scope.settings,
help=_(
- "Enter the LTI 1.3 Tool's public key."
- "
This is a string that starts with '-----BEGIN PUBLIC KEY-----' and is required "
- "so that the LMS can check if the messages and launch requests received have the signature "
- "from the tool."
- "
This is not required when doing LTI 1.3 Launches without LTI Advantage nor "
- "Basic Outcomes requests."
- "
Changing the public key or keyset URL will cause the client ID, block keyset URL "
- "and access token URL to be regenerated if they are shared between blocks. "
- "Please check and update them in the LTI tool settings if necessary."
+ "Enter the tool’s public key in PEM format (starts with -----BEGIN PUBLIC KEY-----). "
+ "Use this when the tool provides a static public key directly."
),
)
lti_1p3_enable_nrps = Boolean(
- display_name=_("Enable LTI NRPS"),
- help=_("Enable LTI Names and Role Provisioning Services."),
+ display_name=_("Names & Roles (NRPS)"),
+ help=_(
+ "Enable this to allow the tool to access the names and roles of enrolled learners, "
+ "if the tool supports NRPS."
+ ),
default=False,
scope=Scope.settings
)
@@ -406,7 +383,9 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock):
# Switch to enable/disable the LTI Advantage Deep linking service
lti_advantage_deep_linking_enabled = Boolean(
display_name=_("Deep linking"),
- help=_("Select True if you want to enable LTI Advantage Deep Linking."),
+ help=_(
+ "Enable this if you want to deep link content from within the tool, in Studio."
+ ),
default=False,
scope=Scope.settings
)
@@ -415,37 +394,30 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock):
default='',
scope=Scope.settings,
help=_(
- "Enter the LTI Advantage Deep Linking Launch URL. If the tool does not specify one, "
- "use the same value as 'Tool Launch URL'."
+ "Enter the deep linking URL for this tool. If the tool does not specify one, enter the 'Launch URL' here."
),
)
lti_advantage_ags_mode = String(
- display_name=_("LTI Assignment and Grades Service"),
+ display_name=_("Assignment and Grades"),
values=[
+ {"display_name": _("Declarative"), "value": "declarative"},
+ {"display_name": _("Programmatic"), "value": "programmatic"},
{"display_name": _("Disabled"), "value": "disabled"},
- {"display_name": _("Allow tools to submit grades only (declarative)"), "value": "declarative"},
- {"display_name": _("Allow tools to manage and submit grade (programmatic)"), "value": "programmatic"},
],
default='declarative',
scope=Scope.settings,
help=_(
- "Enable the LTI-AGS service and select the functionality enabled for LTI tools. "
- "The 'declarative' mode (default) will provide a tool with a LineItem created from the XBlock settings, "
- "while the 'programmatic' one will allow tools to manage, create and link the grades."
+ "Enable AGS and select functionality. 'Declarative' provides the tool with an existing line item. "
+ "'Programmatic' allows tools to create and manage line items."
),
)
# LTI 1.1 fields
lti_id = String(
- display_name=_("LTI ID"),
+ display_name=_("LTI Passport ID"),
help=_(
- "Enter the LTI ID for the external LTI provider. "
- "This value must be the same LTI ID that you entered in the "
- "LTI Passports setting on the Advanced Settings page."
- "
See the {docs_anchor_open}edX LTI documentation{anchor_close} for more details on this setting."
- ).format(
- docs_anchor_open=DOCS_ANCHOR_TAG_OPEN,
- anchor_close=""
+ "Select the LTI passport ID for this tool. This is the ID of the LTI passport that "
+ "you created on Advanced Settings page."
),
default='',
scope=Scope.settings
@@ -453,12 +425,7 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock):
launch_url = String(
display_name=_("LTI URL"),
help=_(
- "Enter the URL of the external tool that this component launches. "
- "This setting is only used when Hide External Tool is set to False."
- "
See the {docs_anchor_open}edX LTI documentation{anchor_close} for more details on this setting."
- ).format(
- docs_anchor_open=DOCS_ANCHOR_TAG_OPEN,
- anchor_close=""
+ "Enter the launch URL for this tool. For LTI 1.1/1.2, this is the URL Open edX uses to launch the tool."
),
default='',
scope=Scope.settings
@@ -468,22 +435,14 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock):
custom_parameters = List(
display_name=_("Custom Parameters"),
help=_(
- "Add the key/value pair for any custom parameters, such as the page your e-book should open to or "
- "the background color for this component. Ex. [\"page=1\", \"color=white\"]"
- "
See the {docs_anchor_open}edX LTI documentation{anchor_close} for more details on this setting."
- ).format(
- docs_anchor_open=DOCS_ANCHOR_TAG_OPEN,
- anchor_close=""
+ 'Enter key-value pairs to send with each launch e.g. ["page=1", "color=white"].'
),
scope=Scope.settings
)
launch_target = String(
- display_name=_("LTI Launch Target"),
+ display_name=_("Open tool in"),
help=_(
- "Select Inline if you want the LTI content to open in an IFrame in the current page. "
- "Select Modal if you want the LTI content to open in a modal window in the current page. "
- "Select New Window if you want the LTI content to open in a new browser window. "
- "This setting is only used when Hide External Tool is set to False."
+ "Choose how the tool opens for learners: inline in the page, in a modal, or in a new window."
),
default=LaunchTarget.IFRAME.value,
scope=Scope.settings,
@@ -494,57 +453,47 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock):
],
)
button_text = String(
- display_name=_("Button Text"),
+ display_name=_("Launch Button Text"),
help=_(
- "Enter the text on the button used to launch the third party application. "
- "This setting is only used when Hide External Tool is set to False and "
- "LTI Launch Target is set to Modal or New Window."
+ "Enter the label shown on the launch button when the tool opens in a modal or new window."
),
default="",
scope=Scope.settings
)
inline_height = Integer(
- display_name=_("Inline Height"),
+ display_name=_("Inline Height (px)"),
help=_(
- "Enter the desired pixel height of the iframe which will contain the LTI tool. "
- "This setting is only used when Hide External Tool is set to False and "
- "LTI Launch Target is set to Inline."
+ "Enter the height of the inline iframe in pixels."
),
default=800,
scope=Scope.settings
)
modal_height = Integer(
- display_name=_("Modal Height"),
+ display_name=_("Modal Height (%)"),
help=_(
- "Enter the desired viewport percentage height of the modal overlay which will contain the LTI tool. "
- "This setting is only used when Hide External Tool is set to False and "
- "LTI Launch Target is set to Modal."
+ "Enter the modal height as a percentage of the browser window."
),
default=80,
scope=Scope.settings
)
modal_width = Integer(
- display_name=_("Modal Width"),
+ display_name=_("Modal Width (%)"),
help=_(
- "Enter the desired viewport percentage width of the modal overlay which will contain the LTI tool. "
- "This setting is only used when Hide External Tool is set to False and "
- "LTI Launch Target is set to Modal."
+ "Enter the modal width as a percentage of the browser window."
),
default=80,
scope=Scope.settings
)
has_score = Boolean(
- display_name=_("Scored"),
- help=_("Select True if this component will receive a numerical score from the external LTI system."),
+ display_name=_("This activity is graded"),
+ help=_("Enable this if the tool sends a numeric score back to Open edX."),
default=False,
scope=Scope.settings
)
weight = Float(
- display_name="Weight",
+ display_name=_("Grade Weight"),
help=_(
- "Enter the number of points possible for this component. "
- "The default value is 1.0. "
- "This setting is only used when Scored is set to True."
+ "Enter the maximum number of points for this component."
),
default=1.0,
scope=Scope.settings,
@@ -563,16 +512,14 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock):
hide_launch = Boolean(
display_name=_("Hide External Tool"),
help=_(
- "Select True if you want to use this component as a placeholder for syncing with an external grading "
- "system rather than launch an external tool. "
- "This setting hides the Launch button and any IFrames for this component."
+ "Enable this to hide launch button and iframe so you use the component for grade sync etc."
),
default=False,
scope=Scope.settings
)
accept_grades_past_due = Boolean(
- display_name=_("Accept grades past deadline"),
- help=_("Select True to allow third party systems to post grades past the deadline."),
+ display_name=_("Accept grades after due date"),
+ help=_("Enable this to allow the tool to send grades after the due date has passed."),
default=True,
scope=Scope.settings
)
@@ -580,31 +527,30 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock):
# party application. When "Open in New Page" is not selected, the tool automatically appears without any
# user action.
ask_to_send_username = Boolean(
- display_name=_("Request user's username"),
+ display_name=_("Share Username"),
# Translators: This is used to request the user's username for a third party service.
- help=_("Select True to request the user's username."),
+ help=_("Enable this to send the learner’s username to the tool."),
default=False,
scope=Scope.settings
)
ask_to_send_full_name = Boolean(
- display_name=_("Request user's full name"),
+ display_name=_("Share Full name"),
# Translators: This is used to request the user's full name for a third party service.
- help=_("Select True to request the user's full name."),
+ help=_("Enable this to send the learner’s full name to the tool."),
default=False,
scope=Scope.settings
)
ask_to_send_email = Boolean(
- display_name=_("Request user's email"),
+ display_name=_("Share Email"),
# Translators: This is used to request the user's email for a third party service.
- help=_("Select True to request the user's email address."),
+ help=_("Enable this to send the learner’s email address to the tool."),
default=False,
scope=Scope.settings
)
enable_processors = Boolean(
display_name=_("Send extra parameters"),
- help=_("Select True to send the extra parameters, which might contain Personally Identifiable Information. "
- "The processors are site-wide, please consult the site administrator if you have any questions."),
+ help=_("Enable this to send extra parameters (may contain PII). Consult site admin for details."),
default=False,
scope=Scope.settings
)
@@ -811,7 +757,12 @@ def editable_fields(self):
# editing of 'ask_to_send_username', 'ask_to_send_full_name', and 'ask_to_send_email'.
pii_sharing_enabled = self.get_pii_sharing_enabled()
if not pii_sharing_enabled:
- noneditable_fields.extend(['ask_to_send_username', 'ask_to_send_full_name', 'ask_to_send_email'])
+ noneditable_fields.extend([
+ 'ask_to_send_username',
+ 'ask_to_send_full_name',
+ 'ask_to_send_email',
+ 'description',
+ ])
editable_fields = tuple(
field
@@ -1221,14 +1172,80 @@ def studio_view(self, context):
Get Studio View fragment
"""
loader = ResourceLoader(__name__)
- fragment = super().studio_view(context)
- fragment.add_javascript(loader.load_unicode("static/js/xblock_studio_view.js"))
+ course = self.course
+ if course:
+ lti_passport_ids = [lti_passport.split(':')[0].strip() for lti_passport in course.lti_passports]
+ else:
+ lti_passport_ids = []
+
+ context = {
+ 'fields': {},
+ 'pii_sharing_enabled': self.get_pii_sharing_enabled(),
+ 'lti_passports': lti_passport_ids,
+ }
+
+ # Add editable fields to context
+ for field_name in self.editable_fields:
+ field = self.fields[field_name] # pylint: disable=unsubscriptable-object
+ field_info = self._make_field_info(field_name, field)
+ if field_info is not None:
+ context["fields"][field_name] = field_info
+
+ i18n_service = self.runtime.service(self, 'i18n')
+
+ # ResourceLoader renders template from string, not Django loader-backed template.
+ # Django `{% include %}` cannot resolve partial paths in this setup, so pre-render
+ # partial templates here and inject safe HTML into wrapper template.
+ context.update({
+ 'stepper_header_html': loader.render_django_template(
+ '/templates/html/lti_studio_edit/_stepper_header.html',
+ context=context,
+ i18n_service=i18n_service,
+ ),
+ 'step_setup_html': loader.render_django_template(
+ '/templates/html/lti_studio_edit/_step_setup.html',
+ context=context,
+ i18n_service=i18n_service,
+ ),
+ 'step_advantage_html': loader.render_django_template(
+ '/templates/html/lti_studio_edit/_step_advantage.html',
+ context=context,
+ i18n_service=i18n_service,
+ ),
+ 'step_review_html': loader.render_django_template(
+ '/templates/html/lti_studio_edit/_step_review.html',
+ context=context,
+ i18n_service=i18n_service,
+ ),
+ 'actions_html': loader.render_django_template(
+ '/templates/html/lti_studio_edit/_actions.html',
+ context=context,
+ i18n_service=i18n_service,
+ ),
+ })
+
+ fragment = Fragment()
+ fragment.add_content(loader.render_django_template(
+ '/templates/html/lti_studio_edit.html',
+ context=context,
+ i18n_service=i18n_service,
+ ))
+
+ fragment.add_css(loader.load_unicode('static/css/xblock_studio_view.css'))
+ fragment.add_javascript(loader.load_unicode('static/js/xblock_studio_view.js'))
+
js_context = {
"EXTERNAL_MULTIPLE_LAUNCH_URLS_ENABLED": external_multiple_launch_urls_enabled(
self.scope_ids.usage_id.course_key
- )
+ ),
+ "editableFields": self.editable_fields,
}
+
+ statici18n_js_url = self._get_statici18n_js_url()
+ if statici18n_js_url:
+ fragment.add_javascript_url(statici18n_js_url)
+
fragment.initialize_js('LtiConsumerXBlockInitStudio', js_context)
return fragment
diff --git a/lti_consumer/static/css/xblock_studio_view.css b/lti_consumer/static/css/xblock_studio_view.css
new file mode 100644
index 00000000..8a8fc1c5
--- /dev/null
+++ b/lti_consumer/static/css/xblock_studio_view.css
@@ -0,0 +1,328 @@
+.pgn__stepper-header {
+ display: flex;
+ flex-direction: row !important;
+ flex: none !important;
+ justify-content: center;
+ align-items: center;
+ background: #00000000;
+ padding: 0.75rem 1rem;
+ height: 5.13rem;
+
+ .pgn__stepper-header-step-list {
+ list-style:none;
+ padding: 0.25rem 0;
+ display: flex;
+ align-items: center;
+ margin: 0;
+ flex-grow: 1;
+ justify-content: center;
+
+ .pgn__stepper-header-line {
+ display: block;
+ height: 1px;
+ background: #E1DDDB;
+ flex-basis: 80px;
+ margin: 0 .5rem;
+ }
+ }
+
+ .pgn__stepper-header-step {
+ a {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ color: #0A3055;
+ flex-shrink: 1;
+ min-width: 0;
+ padding: 0.25rem;
+
+ .pgn__stepper-header-step-title {
+ white-space: nowrap;
+ overflow: hidden;
+ min-width: 0;
+ text-overflow: ellipsis;
+ }
+ }
+
+ &.pgn__stepper-header-step-active {
+ .pgn__bubble {
+ background: #0A3055;
+ }
+ }
+
+ &:not(.pgn__stepper-header-step-active) {
+ .pgn__bubble {
+ background: #707070;
+ }
+ }
+
+ &.pgn__stepper-header-step-active ~ .pgn__stepper-header-step {
+ color: #707070;
+ .pgn__bubble {
+ background: #707070;
+ }
+ }
+ }
+
+ .pgn__bubble {
+ line-height: normal;
+ height: 1.5rem;
+ width: 1.5rem;
+ border-radius: 50%;
+ display: inline-flex !important;
+ margin-inline-end: .5rem;
+ align-items: center;
+ justify-content: center;
+ font-size: 75%;
+ color: #FFFFFF;
+ }
+}
+
+.hidden {
+ display: none !important;
+}
+
+.button-select-options {
+ list-style-type: none;
+ padding: 0;
+ margin-top: 0.5rem;
+ display: flex;
+
+ li {
+ display: inline-block;
+ width: 100px;
+ height: 40px;
+ margin: 0px !important;
+ position: relative;
+ flex-grow: 1;
+
+ &:first-child label {
+ border-radius: 4px 0 0 4px;
+ }
+
+ &:last-child label {
+ border-radius: 0 4px 4px 0;
+ }
+
+ &:not(:first-child) label {
+ /* Remove the left border of all but the first label to avoid double borders. */
+ border-left: none;
+ }
+ }
+
+ input {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ cursor: pointer;
+ }
+
+ label {
+ display: block;
+ padding: 5px;
+ border: 1px solid #0a3055;
+ cursor: pointer;
+ text-align: center;
+ font-weight: 500 !important;
+ font-size: 18px !important;
+ }
+
+ input[type="radio"] {
+ opacity: 0;
+ }
+
+ input[type="radio"]:checked+label {
+ background: #0a3055;
+ color: #fff !important;
+ }
+}
+
+.field-group {
+ flex-grow: 0 !important;
+
+ &.field-group-lti-configuration {
+ ul {
+ display: flex;
+ flex-direction: row !important;
+ }
+
+ li:not(:last-child) {
+ margin-right: 2rem;
+ }
+
+ li.lti-version {
+ width: 200px;
+ }
+
+ li.flex-grow {
+ flex-grow: 1;
+ }
+ }
+
+ .field-group-heading {
+ font-weight: 700;
+ font-size: 12px;
+ line-height: 20px;
+ color: #454545;
+ margin-bottom: 0.5rem;
+ }
+
+ .two-column {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ gap: 1rem;
+
+ li {
+ flex-grow: 1;
+ }
+ }
+}
+
+hr.field-group-separator {
+ border: none;
+ border-top: 2px solid #E9E6E4;
+ margin: 1rem 0;
+ width: 100% !important;
+}
+
+.step-body {
+ padding: 1rem;
+ flex-grow: 1;
+ height: 315px;
+ overflow-y: scroll;
+}
+
+span.icon.fa-question-circle {
+ color: #707070;
+}
+
+div.tooltip {
+ min-width: 300px;
+}
+
+[class*="view-"] {
+ .wrapper .modal-window.modal-editor,
+ .wrapper.xblock-iframe-content {
+ .modal-content {
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
+
+ .xblock-editor {
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
+
+ .xblock-studio_view {
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
+ }
+ }
+ }
+
+ .editor-with-buttons {
+ display: flex;
+ flex-direction: column;
+ margin-bottom: 0;
+ flex-grow: 1;
+
+ .xblock-actions {
+ flex: none;
+ margin-top: auto;
+ flex-direction: row;
+ position: relative;
+
+ .action-buttons-left {
+ margin-right: auto;
+ margin-left: 0;
+ margin-bottom: 0;
+ }
+
+ .action-buttons {
+ flex-grow: 0;
+ }
+
+ .primary-button {
+ color: #fff;
+ background-color: #0a3055;
+ box-shadow: none;
+ font-weight: 500;
+ border: 1px solid transparent;
+ padding: 10px 16px;
+ font-size: 18px;
+ line-height: 20px;
+ border-radius: 6px;
+ text-align: center;
+ vertical-align: middle;
+ user-select: none;
+ background-image: none;
+ height: auto;
+ display: block;
+ margin-right: 0;
+ }
+
+ .secondary-button {
+ color: #333333;
+ background-color: transparent;
+ box-shadow: none;
+ font-weight: 500;
+ border: 1px solid #707070;
+ padding: 10px 16px;
+ font-size: 18px;
+ line-height: 20px;
+ border-radius: 6px;
+ text-align: center;
+ vertical-align: middle;
+ user-select: none;
+ background-image: none;
+ display: block;
+ }
+ }
+ }
+
+ .comp-setting-entry {
+ margin-top: 0.5rem;
+ }
+
+ label:not(.checkbox-label),
+ .label.setting-label:not(.checkbox-label) {
+ font-weight: 700;
+ font-size: 14px;
+ line-height: 32px;
+ color: #082644;
+ }
+
+ select,
+ textarea,
+ input[type="text"] {
+ display: block;
+ margin-top: 0.5rem;
+ width: 100%;
+ }
+
+ select {
+ border: 1px solid #707070;
+ border-radius: 6px;
+ padding: 10px 16px;
+ font-size: 18px;
+ line-height: 20px;
+ color: #082644;
+ font-weight: 500;
+
+ option {
+ color: #0a3055;
+ font-weight: bold;
+ }
+ }
+
+ textarea {
+ resize: none;
+ height: auto;
+ }
+ }
+}
diff --git a/lti_consumer/static/js/xblock_studio_view.js b/lti_consumer/static/js/xblock_studio_view.js
index ef790c93..4e1e5ad2 100644
--- a/lti_consumer/static/js/xblock_studio_view.js
+++ b/lti_consumer/static/js/xblock_studio_view.js
@@ -1,178 +1,529 @@
/**
* Javascript for LTI Consumer Studio View.
-*/
+ */
function LtiConsumerXBlockInitStudio(runtime, element, data) {
- // Run parent function to set up studio view base JS
- StudioEditableXBlockMixin(runtime, element);
-
- // Define LTI 1.1 and 1.3 fields
- const lti1P1FieldList = [
- "lti_id",
- "launch_url"
- ];
-
- const lti1P3FieldList = [
- "lti_1p3_launch_url",
- "lti_1p3_redirect_uris",
- "lti_1p3_oidc_url",
- "lti_1p3_tool_key_mode",
- "lti_1p3_tool_keyset_url",
- "lti_1p3_tool_public_key",
- "lti_advantage_ags_mode",
- "lti_advantage_deep_linking_enabled",
- "lti_advantage_deep_linking_launch_url",
- "lti_1p3_enable_nrps"
- ];
-
- /**
- * Query a field using the `data-field-name` attribute and hide/show it.
- *
- * params:
- * field: string. Value of the field's `data-field-name` attribute.
- * visible: boolean. `true` shows the container, and `false` hides it.
- */
- function toggleFieldVisibility(field, visible) {
- const componentQuery = '[data-field-name="' + field + '"]';
- const fieldContainer = element.find(componentQuery);
-
- if (visible) {
- fieldContainer.show();
- } else {
- fieldContainer.hide();
- }
+ let currentStep = "setup";
+
+ /**
+ * Query a field using the `data-field-name` attribute and hide/show it.
+ *
+ * @param {string} field - Value of the field's `data-field-name` attribute.
+ * @param {boolean} visible - `true` shows the container, and `false` hides it.
+ */
+ function toggleFieldVisibility(field, visible) {
+ const componentQuery = '[data-field-name="' + field + '"]';
+ const fieldContainer = element.find(componentQuery);
+
+ if (visible) {
+ fieldContainer.removeClass("hidden");
+ } else {
+ fieldContainer.addClass("hidden");
+ }
+ }
+
+ /**
+ * Return the value of the selected radio button.
+ * @param {string} fieldName - The name of the field to search for.
+ * @returns {string} The value of the selected radio button.
+ */
+ function getRadioButtonValue(fieldName) {
+ const options = $(element).find(`input[id^=${fieldName}_option-]`);
+
+ for (let i = 0; i < options.length; i++) {
+ if (options[i].checked) {
+ return options[i].value;
+ }
}
- /**
- * Return fields that should be hidden based on the selected lti version.
- */
- function getFieldsToHideForLtiVersion() {
- const ltiVersionField = $(element).find('#xb-field-edit-lti_version');
- const selectedVersion = ltiVersionField.children("option:selected").val();
- const fieldsToHide = [];
-
- if (selectedVersion === undefined || selectedVersion === "lti_1p1") {
- // If LTI version field isn't present, then LTI 1.3 support is disabled
- // so hide all LTI 1.3 fields. If the LTI version is LTI 1.1, also hide all LTI
- // 1.3 fields.
- lti1P3FieldList.forEach(function (field) {
- fieldsToHide.push(field);
- });
- } else if (selectedVersion === "lti_1p3") {
- lti1P1FieldList.forEach(function (field) {
- fieldsToHide.push(field);
- });
- } else { }
-
- return fieldsToHide;
+ throw new Error(`No option selected for ${fieldName}`);
+ }
+
+ /**
+ * Show or hide components depending on the selected lti_version and config_type.
+ */
+ function toggleLtiComponents() {
+ const version = getRadioButtonValue("lti_version");
+ let configType;
+ try {
+ configType = getFieldValue("config_type").value || "new";
+ } catch (e) {
+ // The waffle flag is not enabled, so we default to "new".
+ configType = "new";
}
+ if (version === "lti_1p1") {
+ $(element).find(".l1p1").removeClass("hidden");
+ $(element).find(".l1p3").addClass("hidden");
+ } else {
+ $(element).find(".l1p1").addClass("hidden");
+ $(element).find(".l1p3").removeClass("hidden");
+ if (configType !== "new") {
+ $(element).find(".no-external-config").addClass("hidden");
+ $(element).find(".external-config").removeClass("hidden");
+ } else {
+ $(element).find(".external-config").addClass("hidden");
+ $(element).find(".no-external-config").removeClass("hidden");
+ }
+ }
- /**
- * Return fields that should be hidden based on the selected config type.
- *
- * new - Show all the LTI 1.1/1.3 config fields
- * database - Do not show the LTI 1.1/1.3 config fields
- * external - Show only the External Config ID field
- */
- function getFieldsToHideForLtiConfigType() {
- const configType = $(element).find('#xb-field-edit-config_type').val();
- const databaseConfigHiddenFields = lti1P1FieldList.concat(lti1P3FieldList);
- const externalConfigHiddenFields = lti1P1FieldList.concat(lti1P3FieldList);
- const fieldsToHide = [];
-
- if (configType === "external") {
- // Hide LTI 1.1 and LTI 1.3 tool fields.
- externalConfigHiddenFields.forEach(function (field) {
- fieldsToHide.push(field);
- })
- // Conditionally show the LTI 1.3 launch URL field if external multiple launch URLs are enabled.
- if (data.EXTERNAL_MULTIPLE_LAUNCH_URLS_ENABLED) {
- const index = fieldsToHide.indexOf("lti_1p3_launch_url");
- if (index > -1) {
- fieldsToHide.splice(index, 1);
- }
- }
- } else if (configType === "database") {
- // Hide the LTI 1.1 and LTI 1.3 fields. The XBlock will remain the source of truth for the lti_version,
- // so do not hide it and continue to allow editing it from the XBlock edit menu in Studio.
- databaseConfigHiddenFields.forEach(function (field) {
- fieldsToHide.push(field);
- })
+ if (configType === "new") {
+ toggleFieldVisibility("lti_id", version === "lti_1p1");
+ toggleFieldVisibility("external_config", false);
+ } else {
+ toggleFieldVisibility("lti_id", false);
+ toggleFieldVisibility("external_config", true);
+ }
+
+ if (configType === "external") {
+ if (data.EXTERNAL_MULTIPLE_LAUNCH_URLS_ENABLED) {
+ // Conditionally show the LTI 1.3 launch URL field if external multiple launch URLs are enabled.
+ toggleFieldVisibility("lti_1p3_launch_url", true);
+ if (version === "lti_1p1") {
+ // Hide both field-group-lti-configuration-details as we EXTERNAL_MULTIPLE_LAUNCH_URLS_ENABLED does
+ // not apply to LTI 1.1.
+ $(element).find(".field-group-lti-configuration-details").addClass("hidden");
} else {
- // No fields should be hidden based on a config_type of 'new'.
+ // Show the field-group-lti-configuration-details for LTI 1.3.
+ $(element).find(".field-group-lti-configuration-details.l1p3").removeClass("hidden");
}
+ } else {
+ toggleFieldVisibility("lti_1p3_launch_url", false);
+ // Also hides the field-group-lti-configuration-details as it is empty in this case
+ $(element).find(".field-group-lti-configuration-details.l1p1").addClass("hidden");
+ $(element).find(".field-group-lti-configuration-details.l1p3").addClass("hidden");
+ }
+ } else {
+ // Reset visibility when switching away from external config.
+ toggleFieldVisibility("lti_1p3_launch_url", true);
+ if (version === "lti_1p1") {
+ $(element).find(".field-group-lti-configuration-details.l1p1").removeClass("hidden");
+ } else {
+ $(element).find(".field-group-lti-configuration-details.l1p3").removeClass("hidden");
+ }
+ }
+ }
+
+ /*
+ * Show or hide fields depending on the lti_advantage_deep_linking_enabled option.
+ */
+ function toggleDeepLinking() {
+ const enabled = getRadioButtonValue("lti_advantage_deep_linking_enabled") === "true";
+
+ $(element).find("[data-field-name=lti_advantage_deep_linking_launch_url]").toggleClass("hidden", !enabled);
+ }
+
+ /**
+ * Show or hide fields depending on the selected has_score option.
+ */
+ function toggleHasScore() {
+ const hasScore = getRadioButtonValue("has_score") === "true";
+ const fields = ["weight", "accept_grades_past_due"];
+
+ fields.forEach((field) => toggleFieldVisibility(field, hasScore));
+ }
+
+ /**
+ * Show or hide fields depending on the selected hide_launch and launch_target options.
+ */
+ function toggleHideExternalTool() {
+ const hideExternalTool = getRadioButtonValue("hide_launch") === "true";
+ const fields = {
+ inline_height: ["iframe"],
+ modal_height: ["modal"],
+ modal_width: ["modal"],
+ button_text: ["modal", "new_window"],
+ };
+
+ const launchTarget = getRadioButtonValue("launch_target");
+
+ if (hideExternalTool) {
+ // Hide all related fields
+ [
+ "launch_target",
+ ...Object.keys(fields), // Hide all fields in the object
+ ].forEach((field) => toggleFieldVisibility(field, false));
+ } else {
+ toggleFieldVisibility("launch_target", true);
- return fieldsToHide;
+ // Set visibility according to the launch_target
+ Object.entries(fields).forEach(([field, targets]) =>
+ toggleFieldVisibility(field, targets.includes(launchTarget)),
+ );
}
+ }
- /**
- * Return fields that should be hidden based on the selected key mode. This returns a list of of fields related to
- * lti tool key mode that should be hidden.
- */
- function getFieldsToHideForLtiToolKeyMode() {
- const ltiKeyModeField = $(element).find('#xb-field-edit-lti_1p3_tool_key_mode');
- const selectedKeyMode = ltiKeyModeField.children("option:selected").val();
- const fieldsToHide = [];
-
- if (selectedKeyMode === 'public_key') {
- fieldsToHide.push("lti_1p3_tool_keyset_url");
- } else if (selectedKeyMode === 'keyset_url') {
- fieldsToHide.push("lti_1p3_tool_public_key");
- }
+ /**
+ * Show or hide fields related to the selected lti_1p3_tool_key_mode option.
+ */
+ function toggleLti1p3ToolKeyMode() {
+ const deepLinkingEnabled = getRadioButtonValue("lti_advantage_deep_linking_enabled") === "true";
+ const nprsEnabled = getRadioButtonValue("lti_1p3_enable_nrps") === "true";
+ const agsModeEnabled = getRadioButtonValue("lti_advantage_ags_mode") !== "disabled";
+
+ if (deepLinkingEnabled || nprsEnabled || agsModeEnabled) {
+ $(element).find("[data-field-name=lti_1p3_tool_key_mode]").removeClass("hidden");
+
+ const keyMode = getRadioButtonValue("lti_1p3_tool_key_mode");
- return fieldsToHide;
+ if (keyMode === "public_key") {
+ $(element).find("[data-field-name=lti_1p3_tool_keyset_url]").addClass("hidden");
+ $(element).find("[data-field-name=lti_1p3_tool_public_key]").removeClass("hidden");
+ } else if (keyMode === "keyset_url") {
+ $(element).find("[data-field-name=lti_1p3_tool_keyset_url]").removeClass("hidden");
+ $(element).find("[data-field-name=lti_1p3_tool_public_key]").addClass("hidden");
+ } else {
+ throw new Error("This should never happen");
+ }
+ } else {
+ $(element).find("[data-field-name=lti_1p3_tool_key_mode]").addClass("hidden");
+ $(element).find("[data-field-name=lti_1p3_tool_keyset_url]").addClass("hidden");
+ $(element).find("[data-field-name=lti_1p3_tool_public_key]").addClass("hidden");
}
+ }
- /**
- * Show or hide fields depending on the selected lti_version, config_type, and lti_1p3_tool_key_mode.
- */
- function toggleLtiFields() {
- const configFields = lti1P1FieldList.concat(lti1P3FieldList);
- const hiddenFields = new Set();
-
- // Start with the assumption that all configFields should be visible. After that, we whittle down the
- // list of visible fields based on the values of those fields.
- configFields.forEach(function (field) {
- toggleFieldVisibility(
- field,
- true
- );
- });
-
- let fieldsToHide;
- const hiddenFieldsFilters = [
- getFieldsToHideForLtiVersion,
- getFieldsToHideForLtiConfigType,
- getFieldsToHideForLtiToolKeyMode
- ];
-
- hiddenFieldsFilters.forEach(function (filter) {
- fieldsToHide = filter();
-
- fieldsToHide.forEach(function (field) {
- hiddenFields.add(field);
- })
- })
-
- for (const field of hiddenFields) {
- toggleFieldVisibility(field, false);
- }
+ /**
+ * Hide the current step and deactivate the step header.
+ *
+ * @param {string} step - The step to deactivate.
+ */
+ function deactivateCurrentStep() {
+ $(element).find(`.step-header-${currentStep}`).removeClass("pgn__stepper-header-step-active");
+ $(element).find(`.step-${currentStep}`).addClass("hidden");
+ }
+
+ /**
+ * Show the current step and activate the step header.
+ *
+ * @param {string} step - The step to activate.
+ */
+ function activateCurrentStep() {
+ $(element).find(`.step-header-${currentStep}`).addClass("pgn__stepper-header-step-active");
+ $(element).find(`.step-${currentStep}`).removeClass("hidden");
+ }
+
+ /**
+ * Change the current step.
+ *
+ * @param {string} step - The step to change to.
+ */
+ function changeStep(step) {
+ deactivateCurrentStep();
+ currentStep = step;
+ activateCurrentStep();
+ handlePrevNextButtonVisibility();
+ }
+
+ // Show or hide fields based on the selected options
+ toggleLtiComponents();
+ toggleDeepLinking();
+ toggleLti1p3ToolKeyMode();
+ toggleHasScore();
+ toggleHideExternalTool();
+
+ // Bind events to input/select fields to mark the field as set
+ $(element)
+ .find(".field-data-control")
+ .bind("change input paste", function () {
+ // Add a class to the field to indicate that the value has been changed
+ const wrapper = $(this).closest("li.field");
+ $(wrapper).addClass("is-set");
+ });
+
+ // Bind events to radio fields to mark the field as set
+ $(element)
+ .find("input[type=radio]")
+ .bind("change", function () {
+ // Add a class to the field to indicate that the value has been changed
+ const wrapper = $(this).closest("li.field");
+ $(wrapper).addClass("is-set");
+ });
+
+ // Bind to onChange method of lti_version selector
+ $(element)
+ .find("[id^=lti_version_option-]")
+ .bind("change", function () {
+ toggleLtiComponents();
+ });
+
+ // Bind to onChange method of lti_advantage_deep_linking_enabled selector
+ $(element)
+ .find("[id^=lti_advantage_deep_linking_enabled_option-]")
+ .bind("change", function () {
+ toggleDeepLinking();
+ toggleLti1p3ToolKeyMode();
+ });
+
+ // Bind to onChange method of lti_1p3_enable_nrps selector
+ $(element)
+ .find("[id^=lti_1p3_enable_nrps_option-]")
+ .bind("change", function () {
+ toggleLti1p3ToolKeyMode();
+ });
+
+ // Bind to onChange method of lti_advantage_ags_mode selector
+ $(element)
+ .find("[id^=lti_advantage_ags_mode_option-]")
+ .bind("change", function () {
+ toggleLti1p3ToolKeyMode();
+ });
+
+ // Bind to onChange method of has_score selector
+ $(element)
+ .find("[id^=has_score_option-]")
+ .bind("change", function () {
+ toggleHasScore();
+ });
+
+ // Bind to onChange method of hide_launch selector
+ $(element)
+ .find("[id^=hide_launch_option-]")
+ .bind("change", function () {
+ toggleHideExternalTool();
+ });
+
+ // Bind to onChange method of launch_target selector
+ $(element)
+ .find("[id^=launch_target_option-]")
+ .bind("change", function () {
+ toggleHideExternalTool();
+ });
+
+ // Bind to onChange method of lti_1p3_tool_key_mode selector
+ $(element)
+ .find("[id^=lti_1p3_tool_key_mode_option-]")
+ .bind("change", function () {
+ toggleLti1p3ToolKeyMode();
+ });
+
+ // Bind to onChange method of config_type selector
+ $(element)
+ .find("[id^=xb-field-edit-config_type]")
+ .bind("change", function () {
+ toggleLtiComponents();
+ });
+
+ $(element)
+ .find(".cancel-button")
+ .bind("click", function () {
+ runtime.notify("cancel", {});
+ });
+
+ function handlePrevNextButtonVisibility() {
+ if (currentStep === "setup") {
+ $(element).find(".previous-button").closest("li").addClass("hidden");
+ } else {
+ $(element).find(".previous-button").closest("li").removeClass("hidden");
}
- // Call once component is instanced to hide fields
- toggleLtiFields();
+ if (currentStep === "review") {
+ $(element).find(".next-button").closest("li").addClass("hidden");
+ $(element).find(".save-button").closest("li").removeClass("hidden");
+ } else {
+ $(element).find(".next-button").closest("li").removeClass("hidden");
+ $(element).find(".save-button").closest("li").addClass("hidden");
+ }
+ }
- // Bind to onChange method of lti_version selector
- $(element).find('#xb-field-edit-lti_version').bind('change', function () {
- toggleLtiFields();
+ $(element)
+ .find(".next-button")
+ .bind("click", function (e) {
+ e.preventDefault();
+ let nextStep;
+ const version = getRadioButtonValue("lti_version");
+ let configType;
+ try {
+ configType = getFieldValue("config_type").value || "new";
+ } catch (e) {
+ // The waffle flag is not enabled, so we default to "new".
+ configType = "new";
+ }
+
+ if (currentStep === "setup") {
+ if (version === "lti_1p1" || configType !== "new") {
+ nextStep = "review";
+ } else {
+ nextStep = "advantage";
+ }
+ } else if (currentStep === "advantage") {
+ nextStep = "review";
+ } else if (currentStep === "review") {
+ throw new Error("This should never happen");
+ }
+ changeStep(nextStep);
});
- // Bind to onChange method of lti_1p3_tool_key_mode selector
- $(element).find('#xb-field-edit-lti_1p3_tool_key_mode').bind('change', function () {
- toggleLtiFields();
+ $(element)
+ .find(".previous-button")
+ .bind("click", function (e) {
+ e.preventDefault();
+ let previousStep;
+ const version = getRadioButtonValue("lti_version");
+ let configType;
+ try {
+ configType = getFieldValue("config_type").value || "new";
+ } catch (e) {
+ // The waffle flag is not enabled, so we default to "new".
+ configType = "new";
+ }
+
+ if (currentStep === "setup") {
+ throw new Error("This should never happen");
+ } else if (currentStep === "advantage") {
+ previousStep = "setup";
+ } else if (currentStep === "review") {
+ if (version === "lti_1p1" || configType !== "new") {
+ previousStep = "setup";
+ } else {
+ previousStep = "advantage";
+ }
+ }
+ changeStep(previousStep);
});
- $(element).find('#xb-field-edit-config_type').bind('change', function () {
- toggleLtiFields();
+ $(element)
+ .find(".step-header-setup-link")
+ .bind("click", function (e) {
+ e.preventDefault();
+ changeStep("setup");
+ });
+
+ $(element)
+ .find(".step-header-advantage-link")
+ .bind("click", function (e) {
+ e.preventDefault();
+ changeStep("advantage");
+ });
+
+ $(element)
+ .find(".step-header-review-link")
+ .bind("click", function (e) {
+ e.preventDefault();
+ changeStep("review");
+ });
+
+ /**
+ * Return whether the field is set or not.
+ *
+ * @param {Element} field - The field to check.
+ * @returns {boolean} Whether the field is set or not.
+ */
+ function getIsSet(field) {
+ const wrapper = $(field).closest("li.field");
+ return $(wrapper).hasClass("is-set");
+ }
+
+ /**
+ * Return the value of the field, or `null` if the field is not set.
+ *
+ * @param {string} fieldName - The name of the field to get the value of.
+ * @returns {{isSet: boolean, value: string | null}} The value of the field, or `null` if the field is not set.
+ */
+ function getFieldValue(fieldName) {
+ const field = $(element).find(`#xb-field-edit-${fieldName}`);
+ let value;
+ let isSet;
+
+ if (field.length === 0) {
+ // This is not a text/select field (or is not present), so we get the value of the select option.
+ const options = $(element).find(`input[id^=${fieldName}_option-]`);
+ if (options.length === 0) {
+ // The field is not present, so we return isSet = false and value = null.
+ return {
+ isSet: false,
+ value: null,
+ };
+ }
+ return {
+ isSet: getIsSet(options[0]),
+ value: getRadioButtonValue(fieldName),
+ };
+ } else {
+ isSet = getIsSet(field);
+ if (field.attr("type") === "checkbox") {
+ value = field.prop("checked");
+ } else {
+ value = field.val();
+ }
+ return {
+ isSet,
+ value,
+ };
+ }
+ }
+
+ $(element)
+ .find(".save-button")
+ .bind("click", function () {
+ const { editableFields } = data;
+ const handlerUrl = runtime.handlerUrl(element, "submit_studio_edits");
+ const submitData = { values: {}, defaults: [] };
+ for (const field of editableFields) {
+ const { isSet, value } = getFieldValue(field);
+ if (isSet) {
+ submitData.values[field] = value;
+ } else {
+ submitData.defaults.push(field);
+ }
+ }
+
+ // Transform the custom_parameters field into a list
+ if ("custom_parameters" in submitData.values) {
+ const customParameters = submitData.values.custom_parameters;
+ try {
+ submitData.values.custom_parameters = JSON.parse(customParameters);
+ } catch (e) {
+ runtime.notify("error", {
+ title: gettext("Unable to update settings"),
+ message: gettext("Unable to parse the custom parameters."),
+ });
+ return;
+ }
+ }
+
+ // Transform the lti_1p3_redirect_uris field into a list
+ if ("lti_1p3_redirect_uris" in submitData.values) {
+ const redirectUris = submitData.values.lti_1p3_redirect_uris;
+ try {
+ submitData.values.lti_1p3_redirect_uris = JSON.parse(redirectUris);
+ } catch (e) {
+ runtime.notify("error", {
+ title: gettext("Unable to update settings"),
+ message: gettext("Unable to parse the redirect URIs."),
+ });
+ return;
+ }
+ }
+
+ runtime.notify("save", { state: "start" });
+
+ $.ajax({
+ type: "POST",
+ url: handlerUrl,
+ data: JSON.stringify(submitData),
+ dataType: "json",
+ contentType: "application/json",
+ global: false,
+ success: function () {
+ runtime.notify("save", { state: "end" });
+ },
+ }).fail(function (jqXHR) {
+ var message = gettext(
+ "This may be happening because of an error with our server or your internet connection. Try refreshing the page or making sure you are online.",
+ );
+ if (jqXHR.responseText) {
+ try {
+ message = JSON.parse(jqXHR.responseText).error;
+ if (typeof message === "object" && message.messages) {
+ // e.g. {"error": {"messages": [{"text": "Unknown user 'bob'!", "type": "error"}, ...]}} etc.
+ message = $.map(message.messages, function (msg) {
+ return msg.text;
+ }).join(", ");
+ }
+ } catch (error) {
+ message = jqXHR.responseText.substr(0, 300);
+ }
+ }
+ runtime.notify("error", { title: gettext("Unable to update settings"), message: message });
+ });
});
}
diff --git a/lti_consumer/templates/html/lti_studio_edit.html b/lti_consumer/templates/html/lti_studio_edit.html
new file mode 100644
index 00000000..ac65bca2
--- /dev/null
+++ b/lti_consumer/templates/html/lti_studio_edit.html
@@ -0,0 +1,10 @@
+{% load i18n %}
+