Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: LTI 1.3 reusable configuration #390

26 changes: 22 additions & 4 deletions lti_consumer/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,14 +141,32 @@ def get_lti_1p3_launch_info(
deep_linking_content_items = [item.attributes for item in dl_content_items]

config_id = lti_config.config_id
client_id = lti_config.lti_1p3_client_id
token_url = get_lms_lti_access_token_link(config_id)
keyset_url = get_lms_lti_keyset_link(config_id)
# We set the deployment ID to a default value of 1,
# this will be used on a configuration with a CONFIG_EXTERNAL config store
# if no deployment ID is set on the external configuration.
deployment_id = '1'

# Display LTI launch information from external configuration.
# if an external configuration is being used.
if lti_config.config_store == lti_config.CONFIG_EXTERNAL:
external_config = get_external_config_from_filter({}, lti_config.external_id)
client_id = external_config.get('lti_1p3_client_id')
token_url = external_config.get('lti_1p3_access_token_url')
keyset_url = external_config.get('lti_1p3_keyset_url')
# Show default harcoded deployment ID if no deployment ID
# is set on the external configuration.
deployment_id = external_config.get('lti_1p3_deployment_id') or deployment_id

# Return LTI launch information for end user configuration
return {
'client_id': lti_config.lti_1p3_client_id,
'keyset_url': get_lms_lti_keyset_link(config_id),
'deployment_id': '1',
'client_id': client_id,
'keyset_url': keyset_url,
'deployment_id': deployment_id,
'oidc_callback': get_lms_lti_launch_link(),
'token_url': get_lms_lti_access_token_link(config_id),
'token_url': token_url,
Agrendalath marked this conversation as resolved.
Show resolved Hide resolved
'deep_linking_launch_url': deep_linking_launch_url,
'deep_linking_content_items':
json.dumps(deep_linking_content_items, indent=4) if deep_linking_content_items else None,
Expand Down
18 changes: 10 additions & 8 deletions lti_consumer/lti_xblock.py
Original file line number Diff line number Diff line change
Expand Up @@ -677,6 +677,13 @@ def validate_field_data(self, validation, data):
_("Custom Parameters must be a list")
)))

# Validate external config ID is not missing.
if data.config_type == 'external' and not data.external_config:
_ = self.runtime.service(self, 'i18n').ugettext
validation.add(ValidationMessage(ValidationMessage.ERROR, str(
_('Reusable configuration ID must be set when using external config.'),
)))
Agrendalath marked this conversation as resolved.
Show resolved Hide resolved

# keyset URL and public key are mutually exclusive
if data.lti_1p3_tool_key_mode == 'keyset_url':
data.lti_1p3_tool_public_key = ''
Expand Down Expand Up @@ -1614,10 +1621,7 @@ def _get_lti_block_launch_handler(self):
"""
Return the LTI block launch handler.
"""
# The "external" config_type is not supported for LTI 1.3, only LTI 1.1. Therefore, ensure that we define
# the lti_block_launch_handler using LTI 1.1 logic for "external" config_types.
# TODO: This needs to change when the LTI 1.3 support is added to the external config_type in the future.
if self.lti_version == 'lti_1p1' or self.config_type == "external":
if self.lti_version == 'lti_1p1':
lti_block_launch_handler = self.runtime.handler_url(self, 'lti_launch_handler').rstrip('/?')
else:
launch_data = self.get_lti_1p3_launch_data()
Expand All @@ -1637,10 +1641,8 @@ def _get_lti_1p3_launch_url(self, consumer):
"""
lti_1p3_launch_url = self.lti_1p3_launch_url.strip()

# The "external" config_type is not supported for LTI 1.3, only LTI 1.1. Therefore, ensure that we define
# the lti_1p3_launch_url using the LTI 1.3 consumer only for config_types that support LTI 1.3.
# TODO: This needs to change when the LTI 1.3 support is added to the external config_type in the future.
if consumer and self.lti_version == "lti_1p3" and self.config_type == "database":
# Get LTI launch URL from consumer if using database or external configuration type.
if consumer and self.lti_version == 'lti_1p3' and self.config_type in ('database', 'external'):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something odd happens when I try to use the external configuration directly from the XBlock. When I set the Configuration Type to Reusable Configuration and provide a valid LTI Reusable Configuration ID, the XBlock break with Error: (1048, "Column 'version' cannot be null"). The relevant part of the traceback is:

File "/edx/shared-src/xblock-lti-consumer/lti_consumer/lti_xblock.py", line 1177, in author_view
  return self.student_view(context)
File "/edx/shared-src/xblock-lti-consumer/lti_consumer/lti_xblock.py", line 1227, in student_view
  context.update(self._get_context_for_template())
File "/edx/shared-src/xblock-lti-consumer/lti_consumer/lti_xblock.py", line 1730, in _get_context_for_template
  lti_consumer = self._get_lti_consumer()
File "/edx/shared-src/xblock-lti-consumer/lti_consumer/lti_xblock.py", line 1128, in _get_lti_consumer
  return get_lti_consumer(config_id_for_block(self))
File "/edx/shared-src/xblock-lti-consumer/lti_consumer/api.py", line 96, in config_id_for_block
  config = _get_lti_config_for_block(block)
File "/edx/shared-src/xblock-lti-consumer/lti_consumer/api.py", line 76, in _get_lti_config_for_block
  lti_config = _get_or_create_local_lti_config(
File "/edx/shared-src/xblock-lti-consumer/lti_consumer/api.py", line 46, in _get_or_create_local_lti_config
  lti_config.save()
File "/edx/shared-src/xblock-lti-consumer/lti_consumer/models.py", line 313, in save
  super().save(*args, **kwargs)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is because the external ID set on the XBlock is not found, this is either because the reusable configuration feature is not properly setup or the external configuration doesn't exist. If you check this specific line you can notice that if no config is found the "version" value will return None, once this value is sent to the _get_or_create_local_lti_config function, when it tries to save the new values to the LtiConfiguration it fails since "version" value cannot be null. This could be fixed by adding an extra validation to any required field from the external config on _get_lti_config_for_block but I think it's out of the scope of this PR, an issue about this should be created.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Created #420.

lti_1p3_launch_url = consumer.launch_url

return lti_1p3_launch_url
Expand Down
28 changes: 28 additions & 0 deletions lti_consumer/migrations/0018_add_ags_multi_tenancy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Generated by Django 3.2.17 on 2023-07-07 02:57

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('lti_consumer', '0017_lticonfiguration_lti_1p3_redirect_uris'),
]

operations = [
migrations.AddField(
model_name='ltiagslineitem',
name='client_id',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name='ltiagslineitem',
name='deployment_id',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name='ltiagslineitem',
name='oidc_url',
field=models.CharField(blank=True, max_length=255, null=True),
),
]
104 changes: 78 additions & 26 deletions lti_consumer/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -351,9 +351,6 @@ def get_lti_advantage_ags_mode(self):
"""
Return LTI 1.3 Advantage Assignment and Grade Services mode.
"""
if self.config_store == self.CONFIG_EXTERNAL:
# TODO: Add support for CONFIG_EXTERNAL for LTI 1.3.
raise NotImplementedError
Agrendalath marked this conversation as resolved.
Show resolved Hide resolved
if self.config_store == self.CONFIG_ON_DB:
return self.lti_advantage_ags_mode
else:
Expand All @@ -364,9 +361,6 @@ def get_lti_advantage_deep_linking_enabled(self):
"""
Return whether LTI 1.3 Advantage Deep Linking is enabled.
"""
if self.config_store == self.CONFIG_EXTERNAL:
# TODO: Add support for CONFIG_EXTERNAL for LTI 1.3.
raise NotImplementedError("CONFIG_EXTERNAL is not supported for LTI 1.3 Advantage services: %s")
if self.config_store == self.CONFIG_ON_DB:
return self.lti_advantage_deep_linking_enabled
else:
Expand All @@ -377,9 +371,6 @@ def get_lti_advantage_deep_linking_launch_url(self):
"""
Return the LTI 1.3 Advantage Deep Linking launch URL.
"""
if self.config_store == self.CONFIG_EXTERNAL:
# TODO: Add support for CONFIG_EXTERNAL for LTI 1.3.
raise NotImplementedError("CONFIG_EXTERNAL is not supported for LTI 1.3 Advantage services: %s")
if self.config_store == self.CONFIG_ON_DB:
return self.lti_advantage_deep_linking_launch_url
else:
Expand All @@ -390,9 +381,6 @@ def get_lti_advantage_nrps_enabled(self):
"""
Return whether LTI 1.3 Advantage Names and Role Provisioning Services is enabled.
"""
if self.config_store == self.CONFIG_EXTERNAL:
# TODO: Add support for CONFIG_EXTERNAL for LTI 1.3.
raise NotImplementedError("CONFIG_EXTERNAL is not supported for LTI 1.3 Advantage services: %s")
if self.config_store == self.CONFIG_ON_DB:
return self.lti_advantage_enable_nrps
else:
Expand All @@ -414,7 +402,32 @@ def _setup_lti_1p3_ags(self, consumer):
log.info('LTI Advantage AGS is disabled for %s', self)
return

lineitem = self.ltiagslineitem_set.first()
# We set the deployment ID to a default value of 1,
# this will be used on a configuration with a CONFIG_EXTERNAL config store
# if no deployment ID is set on the external configuration.
deployment_id = 1
Agrendalath marked this conversation as resolved.
Show resolved Hide resolved

# Get OIDC URL, Client ID and Deployment ID (On CONFIG_EXTERNAL)
# to use it to retrieve/save the tool deployment LineItem.
if self.config_store == self.CONFIG_ON_XBLOCK:
block = compat.load_enough_xblock(self.location)
oidc_url = block.lti_1p3_oidc_url
client_id = block.lti_1p3_client_id
elif self.config_store == self.CONFIG_EXTERNAL:
config = get_external_config_from_filter({}, self.external_id)
oidc_url = config.get('lti_1p3_oidc_url')
client_id = config.get('lti_1p3_client_id')
deployment_id = config.get('lti_1p3_deployment_id') or deployment_id
else:
oidc_url = self.lti_1p3_oidc_url
client_id = self.lti_1p3_client_id

lineitem = self.ltiagslineitem_set.filter(
oidc_url=oidc_url,
client_id=client_id,
deployment_id=deployment_id,
).first()

# If using the declarative approach, we should create a LineItem if it
# doesn't exist. This is because on this mode the tool is not able to create
# and manage lineitems using the AGS endpoints.
Expand Down Expand Up @@ -447,6 +460,9 @@ def _setup_lti_1p3_ags(self, consumer):
lineitem = LtiAgsLineItem.objects.create(
lti_configuration=self,
resource_link_id=self.location,
oidc_url=oidc_url,
client_id=client_id,
deployment_id=deployment_id,
**default_values
)

Expand Down Expand Up @@ -534,9 +550,30 @@ def _get_lti_1p3_consumer(self):
tool_key=self.lti_1p3_tool_public_key,
tool_keyset_url=self.lti_1p3_tool_keyset_url,
)
elif self.config_store == self.CONFIG_EXTERNAL:
block = compat.load_enough_xblock(self.location)
external = get_external_config_from_filter({}, self.external_id)

consumer = consumer_class(
iss=get_lti_api_base(),
# Use the xblock values if lti_1p3_oidc_url or lti_1p3_launch_url
# is not set on the external configuration.
lti_oidc_url=block.lti_1p3_oidc_url or external.get('lti_1p3_oidc_url'),
lti_launch_url=block.lti_1p3_launch_url or external.get('lti_1p3_launch_url'),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we introduce the XBlock configuration as a fallback? What about the value stored in the DB (self.lti_1p3_oidc_url)?
Having a fallback is helpful, but this behavior should be clearly documented.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We introduce it so course authors can override the OIDC URL, launch URL or deep linking launch URL, without the need to create a new configuration. We could add also the DB values as a fallback, I didn't wanted to introduce to much options on this fallback but also having the fallback on the DB might be useful. What do you suggest this be documented on?, I just thought about adding a code comment to describe this behavior but I agree it might be better to have it documented.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kuipumu , ah, right, we use them as overrides, not fallbacks.

Should we also override other values (like client_id, rsa_key) through the LtiConfiguration? Quick context: I'm talking only about the LtiConfiguration or ExternalConfiguration condition since these values cannot be easily modified (or are not stored) within the XBlock fields.

I believe that the most discoverable solution would be adding this information directly to the help text of each XBlock/DB field that can be overridden this way. Something like "If the configuration is retrieved from an external store, this value overrides the one specified in the external configuration.", but with an indication that this is not applicable when you use the CONFIG_ON_DB option.

@Zacharis278, what do you think?

Copy link
Contributor

@Zacharis278 Zacharis278 Sep 1, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think an issue we run into with the override functionality is right now the configuration options are exclusive. You pick either xblock, database, or external. If you choose external it's ambiguous what the next most important configuration location would be. I'd agree making this not applicable to CONFIG_ON_DB is probably the simplest short term solution around that. That said, this would now differ from the 1.1 implementation. If we're going to add an override feature its behavior should be consistent across LTI versions.

I'm leaning towards suggesting we break override behavior out into it's own PR and instead focus on parity with the existing 1.1 solution in here. Taking time to discuss/consider the full list of fields that are potentially overridable at the block level and how we visually communicate to course teams how it would work might hold up the core behavior of this PR.

Copy link
Contributor Author

@kuipumu kuipumu Sep 6, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Zacharis278 I agree that we should make this PR only about making LTI 1.3 external configuration 1:1 with the LTI 1.1 implementation and move other specific features (multi-tenancy, field override) into separate PRs, I will create these PRs has soon as we merge this changes. I already removed anything related to field overrides and multi-tenancy from the PR.

client_id=external.get('lti_1p3_client_id'),
# Deployment ID hardcoded to 1 if no deployment ID is set
# on the external configuration.
deployment_id=external.get('lti_1p3_deployment_id') or '1',
rsa_key=external.get('lti_1p3_private_key'),
rsa_key_id=external.get('lti_1p3_private_key_id'),
# Registered redirect uris
redirect_uris=self.get_lti_1p3_redirect_uris(),
tool_key=external.get('lti_1p3_tool_public_key'),
tool_keyset_url=external.get('lti_1p3_tool_keyset_url'),
)
else:
# This should not occur, but raise an error if self.config_store is not CONFIG_ON_XBLOCK
# or CONFIG_ON_DB.
# This should not occur, but raise an error if self.config_store is not
# CONFIG_ON_XBLOCK, CONFIG_ON_DB or CONFIG_EXTERNAL.
raise NotImplementedError

if isinstance(consumer, LtiAdvantageConsumer):
Expand All @@ -560,10 +597,14 @@ def get_lti_1p3_redirect_uris(self):
Return pre-registered redirect uris or sensible defaults
"""
if self.config_store == self.CONFIG_EXTERNAL:
# TODO: Add support for CONFIG_EXTERNAL for LTI 1.3.
raise NotImplementedError

if self.config_store == self.CONFIG_ON_DB:
block = compat.load_enough_xblock(self.location)
config = get_external_config_from_filter({}, self.external_id)
redirect_uris = config.get('lti_1p3_redirect_uris') or block.lti_1p3_redirect_uris
launch_url = config.get('lti_1p3_launch_url') or block.lti_1p3_launch_url
deep_link_launch_url = config.get(
'lti_advantage_deep_linking_launch_url',
) or block.lti_advantage_deep_linking_launch_url
Agrendalath marked this conversation as resolved.
Show resolved Hide resolved
elif self.config_store == self.CONFIG_ON_DB:
redirect_uris = self.lti_1p3_redirect_uris
launch_url = self.lti_1p3_launch_url
deep_link_launch_url = self.lti_advantage_deep_linking_launch_url
Expand Down Expand Up @@ -609,10 +650,6 @@ class LtiAgsLineItem(models.Model):
LTI-AGS Specification: https://www.imsglobal.org/spec/lti-ags/v2p0
The platform MUST NOT modify the 'resourceId', 'resourceLinkId' and 'tag' values.

Note: When implementing multi-tenancy support, this needs to be changed
and be tied to a deployment ID, because each deployment should isolate
it's resources.

.. no_pii:
"""
# LTI Configuration link
Expand All @@ -626,6 +663,24 @@ class LtiAgsLineItem(models.Model):
blank=True
)

# OIDC URL, Client ID and Deployment ID
# This allows us to isolate the LineItem per tool deployment
oidc_url = models.CharField(
max_length=255,
blank=True,
null=True,
)
client_id = models.CharField(
max_length=255,
blank=True,
null=True,
)
deployment_id = models.CharField(
max_length=255,
blank=True,
null=True,
)

Copy link
Member

@Agrendalath Agrendalath Aug 22, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this change backward-compatible? Shouldn't we fill these fields in the migration?

Also, why are we adding these fields to this model? A single LtiConfiguration entity can have only one set of oidc_url, client_id and deployment_id values. The LtiAgsLineItem entity can be identified by the resource_link_id. If I understand this correctly, as long as the deployment ID is defined in the database configs (instead of being tied to, e.g., an organization or a Django Site), this relation should not change.

Using the single-tenant deployments was a deliberate architectural decision, so if we want to change it, it would be reasonable to write a new ADR for this. We should also prepare the documentation that explains how to configure it. We shouldn't add undocumented features.
It would be better to split this functionality into a separate PR.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A single LtiConfiguration entity can only have one set of oidc_url, client_id, and deployment_id values set on the DB and XBlock, but once we use external configurations, the oidc_url, client_id and deployment_id values on the external configuration could change, this will mean that the gradebook won't change even if the oidc_url, client_id and deployment_id values changed for the LtiConfiguration. Since each lineitem should only expose gradebook information directly tied to the tool deployment, I modified the LtiAgsLineItem model to include the oidc_url, client_id and deployment_id so we are querying the LtiAgsLineItem on LtiAgsLineItemViewset not only by the lti_configuration but also by the values currently being used by the LtiConfiguration. I might be wrong about this, so please correct me if I'm wrong.

Also, you are correct, this fields should be filled in the migration, otherwise, this change wouldn't be backward compatible if applied.

We noticed the possibility of using deployment IDs while using an external configuration, I can separate anything related to implementing the use of multi-tenancy with external configurations on a separate PR to avoid increasing the complexity of the initial implementation of external configurations for LTI 1.3. What do you think if I add an ADR on how to set external configurations for LTI 1.3 and another ADR about external configurations multi-tenancy on this other PR I will open?.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kuipumu, yes, please move the multi-tenancy-related things to a separate PR. Once we merge the reusable LTI 1.3 configs, catching potential regressions will be much easier.

What do you think if I add an ADR on how to set external configurations for LTI 1.3 and another ADR about external configurations multi-tenancy on this other PR I will open?.

Sounds reasonable to me. Do we have a rough timeline for this separate PR? It would be good to add the LTI 1.3 external configurations ADR before the Quince release.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Agrendalath I removed anything related to multi-tenancy from this PR, I don't have a rough timeline for this PR, I would think that has soon has we end merging this feature I can create a PR for anything related to multi-tenancy and external configurations.

I will work on an ADR ASAP to include in this PR related to LTI 1.3 external configurations, the Quince release is expected to be released when?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kuipumu, Quince will be released in December.

# Tool resource identifier, not used by the LMS.
resource_id = models.CharField(max_length=100, blank=True)

Expand Down Expand Up @@ -663,9 +718,6 @@ class LtiAgsScore(models.Model):
Model to store LineItem Score data for LTI Assignments and Grades service.

LTI-AGS Specification: https://www.imsglobal.org/spec/lti-ags/v2p0
Note: When implementing multi-tenancy support, this needs to be changed
and be tied to a deployment ID, because each deployment should isolate
it's resources.

.. no_pii:
"""
Expand Down
20 changes: 13 additions & 7 deletions lti_consumer/plugin/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -556,19 +556,25 @@ class LtiAgsLineItemViewset(viewsets.ModelViewSet):

def get_queryset(self):
lti_configuration = self.request.lti_configuration
consumer_dict = lti_configuration.get_lti_consumer().__dict__

# Return all LineItems related to the LTI configuration.
# TODO:
# Note that each configuration currently maps 1:1
# to each resource link (block), and this filter needs
# improved once we start reusing LTI configurations.
return LtiAgsLineItem.objects.filter(
lti_configuration=lti_configuration
lti_configuration=lti_configuration,
oidc_url=consumer_dict.get('oidc_url'),
client_id=consumer_dict.get('client_id'),
deployment_id=consumer_dict.get('deployment_id'),
)

def perform_create(self, serializer):
lti_configuration = self.request.lti_configuration
serializer.save(lti_configuration=lti_configuration)
consumer_dict = lti_configuration.get_lti_consumer().__dict__

serializer.save(
lti_configuration=lti_configuration,
oidc_url=consumer_dict.get('oidc_url'),
client_id=consumer_dict.get('client_id'),
deployment_id=consumer_dict.get('deployment_id'),
)

@action(
detail=True,
Expand Down
20 changes: 10 additions & 10 deletions lti_consumer/static/js/xblock_studio_view.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,33 +72,33 @@ function LtiConsumerXBlockInitStudio(runtime, element) {
*
* 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
* external - Show all LTI 1.3 config fields except LTI 1.3 tool fields
Agrendalath marked this conversation as resolved.
Show resolved Hide resolved
*/
function getFieldsToHideForLtiConfigType() {
const configType = $(element).find('#xb-field-edit-config_type').val();
const configFields = lti1P1FieldList.concat(lti1P3FieldList);
const databaseConfigHiddenFields = lti1P1FieldList.concat(lti1P3FieldList);
const externalConfigHiddenFields = lti1P1FieldList.concat([
'lti_1p3_tool_key_mode',
'lti_1p3_tool_keyset_url',
'lti_1p3_tool_public_key',
]);
const fieldsToHide = [];

if (configType === "external") {
// Hide the lti_version field and all the LTI 1.1 and LTI 1.3 fields.
configFields.forEach(function (field) {
// Hide LTI 1.1 and LTI 1.3 tool fields.
externalConfigHiddenFields.forEach(function (field) {
fieldsToHide.push(field);
})
fieldsToHide.push("lti_version");
} 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.
configFields.forEach(function (field) {
databaseConfigHiddenFields.forEach(function (field) {
fieldsToHide.push(field);
})
} else {
// No fields should be hidden based on a config_type of 'new'.
}

if (configType === "external") {
fieldsToHide.push("external_config");
}

return fieldsToHide;
}

Expand Down
Loading
Loading