mirror of
https://github.com/Sudo-JHare/FHIRFLARE-IG-Toolkit.git
synced 2025-06-15 13:09:59 +00:00
incremental update
Updated, cliboard copy buttons on examples, added new UI elements, updated and added more validation for samples, and switched validator to ajax
This commit is contained in:
parent
4d91c6a3d5
commit
c2e805b29a
41
app.py
41
app.py
@ -1,4 +1,3 @@
|
|||||||
# app.py
|
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
sys.path.append(os.path.abspath(os.path.dirname(__file__)))
|
sys.path.append(os.path.abspath(os.path.dirname(__file__)))
|
||||||
@ -12,7 +11,8 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import requests
|
import requests
|
||||||
import re
|
import re
|
||||||
import services
|
import services # Restore full module import
|
||||||
|
from services import services_bp # Keep Blueprint import
|
||||||
from forms import IgImportForm, ValidationForm
|
from forms import IgImportForm, ValidationForm
|
||||||
|
|
||||||
# Set up logging
|
# Set up logging
|
||||||
@ -47,6 +47,9 @@ except Exception as e:
|
|||||||
db = SQLAlchemy(app)
|
db = SQLAlchemy(app)
|
||||||
csrf = CSRFProtect(app)
|
csrf = CSRFProtect(app)
|
||||||
|
|
||||||
|
# Register Blueprint
|
||||||
|
app.register_blueprint(services_bp, url_prefix='/api')
|
||||||
|
|
||||||
class ProcessedIg(db.Model):
|
class ProcessedIg(db.Model):
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
package_name = db.Column(db.String(128), nullable=False)
|
package_name = db.Column(db.String(128), nullable=False)
|
||||||
@ -786,10 +789,9 @@ def api_push_ig():
|
|||||||
logger.error(f"[API Push] Error yielding final error message: {yield_e}")
|
logger.error(f"[API Push] Error yielding final error message: {yield_e}")
|
||||||
return Response(generate_stream(), mimetype='application/x-ndjson')
|
return Response(generate_stream(), mimetype='application/x-ndjson')
|
||||||
|
|
||||||
@app.route('/validate-sample', methods=['GET', 'POST'])
|
@app.route('/validate-sample', methods=['GET'])
|
||||||
def validate_sample():
|
def validate_sample():
|
||||||
form = ValidationForm()
|
form = ValidationForm()
|
||||||
validation_report = None
|
|
||||||
packages = []
|
packages = []
|
||||||
packages_dir = app.config['FHIR_PACKAGES_DIR']
|
packages_dir = app.config['FHIR_PACKAGES_DIR']
|
||||||
if os.path.exists(packages_dir):
|
if os.path.exists(packages_dir):
|
||||||
@ -807,40 +809,11 @@ def validate_sample():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Error reading package {filename}: {e}")
|
logger.warning(f"Error reading package {filename}: {e}")
|
||||||
continue
|
continue
|
||||||
if form.validate_on_submit():
|
|
||||||
package_name = form.package_name.data
|
|
||||||
version = form.version.data
|
|
||||||
include_dependencies = form.include_dependencies.data
|
|
||||||
mode = form.mode.data
|
|
||||||
try:
|
|
||||||
sample_input = json.loads(form.sample_input.data)
|
|
||||||
if mode == 'single':
|
|
||||||
validation_report = services.validate_resource_against_profile(
|
|
||||||
package_name, version, sample_input, include_dependencies
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
validation_report = services.validate_bundle_against_profile(
|
|
||||||
package_name, version, sample_input, include_dependencies
|
|
||||||
)
|
|
||||||
flash("Validation completed.", 'success')
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
flash("Invalid JSON format in sample input.", 'error')
|
|
||||||
validation_report = {'valid': False, 'errors': ['Invalid JSON format provided.'], 'warnings': [], 'results': {}}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error validating sample: {e}")
|
|
||||||
flash(f"Error validating sample: {str(e)}", 'error')
|
|
||||||
validation_report = {'valid': False, 'errors': [str(e)], 'warnings': [], 'results': {}}
|
|
||||||
else:
|
|
||||||
for field, errors in form.errors.items():
|
|
||||||
field_obj = getattr(form, field, None)
|
|
||||||
field_label = field_obj.label.text if field_obj and hasattr(field_obj, 'label') else field
|
|
||||||
for error in errors:
|
|
||||||
flash(f"Error in field '{field_label}': {error}", "danger")
|
|
||||||
return render_template(
|
return render_template(
|
||||||
'validate_sample.html',
|
'validate_sample.html',
|
||||||
form=form,
|
form=form,
|
||||||
packages=packages,
|
packages=packages,
|
||||||
validation_report=validation_report,
|
validation_report=None,
|
||||||
site_name='FHIRFLARE IG Toolkit',
|
site_name='FHIRFLARE IG Toolkit',
|
||||||
now=datetime.datetime.now()
|
now=datetime.datetime.now()
|
||||||
)
|
)
|
||||||
|
Binary file not shown.
282
services.py
282
services.py
@ -4,11 +4,14 @@ import tarfile
|
|||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import logging
|
import logging
|
||||||
from flask import current_app
|
from flask import current_app, Blueprint, request, jsonify
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
|
# Define Blueprint
|
||||||
|
services_bp = Blueprint('services', __name__)
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||||
@ -53,7 +56,6 @@ FHIR_R4_BASE_TYPES = {
|
|||||||
"SubstanceSourceMaterial", "SubstanceSpecification", "SupplyDelivery", "SupplyRequest", "Task",
|
"SubstanceSourceMaterial", "SubstanceSpecification", "SupplyDelivery", "SupplyRequest", "Task",
|
||||||
"TerminologyCapabilities", "TestReport", "TestScript", "ValueSet", "VerificationResult", "VisionPrescription"
|
"TerminologyCapabilities", "TestReport", "TestScript", "ValueSet", "VerificationResult", "VisionPrescription"
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Helper Functions ---
|
# --- Helper Functions ---
|
||||||
|
|
||||||
def _get_download_dir():
|
def _get_download_dir():
|
||||||
@ -132,8 +134,8 @@ def parse_package_filename(filename):
|
|||||||
version = ""
|
version = ""
|
||||||
return name, version
|
return name, version
|
||||||
|
|
||||||
def find_and_extract_sd(tgz_path, resource_identifier):
|
def find_and_extract_sd(tgz_path, resource_identifier, profile_url=None):
|
||||||
"""Helper to find and extract StructureDefinition json from a tgz path."""
|
"""Helper to find and extract StructureDefinition json from a tgz path, prioritizing profile match."""
|
||||||
sd_data = None
|
sd_data = None
|
||||||
found_path = None
|
found_path = None
|
||||||
if not tgz_path or not os.path.exists(tgz_path):
|
if not tgz_path or not os.path.exists(tgz_path):
|
||||||
@ -159,17 +161,23 @@ def find_and_extract_sd(tgz_path, resource_identifier):
|
|||||||
sd_name = data.get('name')
|
sd_name = data.get('name')
|
||||||
sd_type = data.get('type')
|
sd_type = data.get('type')
|
||||||
sd_url = data.get('url')
|
sd_url = data.get('url')
|
||||||
if resource_identifier and (
|
# Prioritize match with profile_url if provided
|
||||||
|
if profile_url and sd_url == profile_url:
|
||||||
|
sd_data = data
|
||||||
|
found_path = member.name
|
||||||
|
logger.info(f"Found SD matching profile '{profile_url}' at path: {found_path}")
|
||||||
|
break
|
||||||
|
# Fallback to resource type or identifier match
|
||||||
|
elif resource_identifier and (
|
||||||
(sd_id and resource_identifier.lower() == sd_id.lower()) or
|
(sd_id and resource_identifier.lower() == sd_id.lower()) or
|
||||||
(sd_name and resource_identifier.lower() == sd_name.lower()) or
|
(sd_name and resource_identifier.lower() == sd_name.lower()) or
|
||||||
(sd_type and resource_identifier.lower() == sd_type.lower()) or
|
(sd_type and resource_identifier.lower() == sd_type.lower()) or
|
||||||
(sd_url and resource_identifier.lower() == sd_url.lower()) or
|
|
||||||
(os.path.splitext(os.path.basename(member.name))[0].lower() == resource_identifier.lower())
|
(os.path.splitext(os.path.basename(member.name))[0].lower() == resource_identifier.lower())
|
||||||
):
|
):
|
||||||
sd_data = data
|
sd_data = data
|
||||||
found_path = member.name
|
found_path = member.name
|
||||||
logger.info(f"Found matching SD for '{resource_identifier}' at path: {found_path}")
|
logger.info(f"Found matching SD for '{resource_identifier}' at path: {found_path}")
|
||||||
break
|
# Continue searching in case a profile match is found
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
logger.debug(f"Could not parse JSON in {member.name}, skipping: {e}")
|
logger.debug(f"Could not parse JSON in {member.name}, skipping: {e}")
|
||||||
except UnicodeDecodeError as e:
|
except UnicodeDecodeError as e:
|
||||||
@ -182,7 +190,7 @@ def find_and_extract_sd(tgz_path, resource_identifier):
|
|||||||
if fileobj:
|
if fileobj:
|
||||||
fileobj.close()
|
fileobj.close()
|
||||||
if sd_data is None:
|
if sd_data is None:
|
||||||
logger.info(f"SD matching identifier '{resource_identifier}' not found within archive {os.path.basename(tgz_path)}")
|
logger.info(f"SD matching identifier '{resource_identifier}' or profile '{profile_url}' not found within archive {os.path.basename(tgz_path)}")
|
||||||
except tarfile.ReadError as e:
|
except tarfile.ReadError as e:
|
||||||
logger.error(f"Tar ReadError reading {tgz_path}: {e}")
|
logger.error(f"Tar ReadError reading {tgz_path}: {e}")
|
||||||
return None, None
|
return None, None
|
||||||
@ -196,7 +204,7 @@ def find_and_extract_sd(tgz_path, resource_identifier):
|
|||||||
logger.error(f"Unexpected error in find_and_extract_sd for {tgz_path}: {e}", exc_info=True)
|
logger.error(f"Unexpected error in find_and_extract_sd for {tgz_path}: {e}", exc_info=True)
|
||||||
raise
|
raise
|
||||||
return sd_data, found_path
|
return sd_data, found_path
|
||||||
|
|
||||||
# --- Metadata Saving/Loading ---
|
# --- Metadata Saving/Loading ---
|
||||||
def save_package_metadata(name, version, dependency_mode, dependencies, complies_with_profiles=None, imposed_profiles=None):
|
def save_package_metadata(name, version, dependency_mode, dependencies, complies_with_profiles=None, imposed_profiles=None):
|
||||||
"""Saves dependency mode, imported dependencies, and profile relationships as metadata."""
|
"""Saves dependency mode, imported dependencies, and profile relationships as metadata."""
|
||||||
@ -250,7 +258,6 @@ def get_package_metadata(name, version):
|
|||||||
else:
|
else:
|
||||||
logger.debug(f"Metadata file not found: {metadata_path}")
|
logger.debug(f"Metadata file not found: {metadata_path}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# --- Package Processing ---
|
# --- Package Processing ---
|
||||||
|
|
||||||
def process_package_file(tgz_path):
|
def process_package_file(tgz_path):
|
||||||
@ -393,7 +400,7 @@ def process_package_file(tgz_path):
|
|||||||
finally:
|
finally:
|
||||||
if fileobj: fileobj.close()
|
if fileobj: fileobj.close()
|
||||||
|
|
||||||
# --- Pass 2: Process Examples ---
|
# Pass 2: Process Examples
|
||||||
logger.debug("Processing Examples...")
|
logger.debug("Processing Examples...")
|
||||||
example_members = [m for m in members if m.isfile() and m.name.startswith('package/') and 'example' in m.name.lower()]
|
example_members = [m for m in members if m.isfile() and m.name.startswith('package/') and 'example' in m.name.lower()]
|
||||||
|
|
||||||
@ -433,7 +440,7 @@ def process_package_file(tgz_path):
|
|||||||
break
|
break
|
||||||
elif profile_url in resource_info:
|
elif profile_url in resource_info:
|
||||||
associated_key = profile_url
|
associated_key = profile_url
|
||||||
personally_match = True
|
found_profile_match = True
|
||||||
logger.debug(f"Example {member.name} associated with profile {associated_key} via meta.profile")
|
logger.debug(f"Example {member.name} associated with profile {associated_key} via meta.profile")
|
||||||
break
|
break
|
||||||
|
|
||||||
@ -475,7 +482,7 @@ def process_package_file(tgz_path):
|
|||||||
finally:
|
finally:
|
||||||
if fileobj: fileobj.close()
|
if fileobj: fileobj.close()
|
||||||
|
|
||||||
# --- Pass 3: Ensure Relevant Base Types ---
|
# Pass 3: Ensure Relevant Base Types
|
||||||
logger.debug("Ensuring relevant FHIR R4 base types...")
|
logger.debug("Ensuring relevant FHIR R4 base types...")
|
||||||
essential_types = {'CapabilityStatement'}
|
essential_types = {'CapabilityStatement'}
|
||||||
for type_name in referenced_types | essential_types:
|
for type_name in referenced_types | essential_types:
|
||||||
@ -485,7 +492,7 @@ def process_package_file(tgz_path):
|
|||||||
resource_info[type_name]['is_profile'] = False
|
resource_info[type_name]['is_profile'] = False
|
||||||
logger.debug(f"Added base type entry for {type_name}")
|
logger.debug(f"Added base type entry for {type_name}")
|
||||||
|
|
||||||
# --- Final Consolidation ---
|
# Final Consolidation
|
||||||
final_list = []
|
final_list = []
|
||||||
final_ms_elements = {}
|
final_ms_elements = {}
|
||||||
final_examples = {}
|
final_examples = {}
|
||||||
@ -559,12 +566,18 @@ def process_package_file(tgz_path):
|
|||||||
|
|
||||||
# --- Validation Functions ---
|
# --- Validation Functions ---
|
||||||
def navigate_fhir_path(resource, path, extension_url=None):
|
def navigate_fhir_path(resource, path, extension_url=None):
|
||||||
"""Navigates a FHIR resource using a FHIRPath-like expression."""
|
"""Navigates a FHIR resource using a FHIRPath-like expression, handling nested structures."""
|
||||||
|
logger.debug(f"Navigating FHIR path: {path}")
|
||||||
if not resource or not path:
|
if not resource or not path:
|
||||||
return None
|
return None
|
||||||
parts = path.split('.')
|
parts = path.split('.')
|
||||||
current = resource
|
current = resource
|
||||||
for part in parts:
|
resource_type = resource.get('resourceType')
|
||||||
|
for i, part in enumerate(parts):
|
||||||
|
# Skip resource type prefix (e.g., Patient)
|
||||||
|
if i == 0 and part == resource_type:
|
||||||
|
continue
|
||||||
|
# Handle array indexing (e.g., name[0])
|
||||||
match = re.match(r'^(\w+)\[(\d+)\]$', part)
|
match = re.match(r'^(\w+)\[(\d+)\]$', part)
|
||||||
if match:
|
if match:
|
||||||
key, index = match.groups()
|
key, index = match.groups()
|
||||||
@ -573,17 +586,60 @@ def navigate_fhir_path(resource, path, extension_url=None):
|
|||||||
if isinstance(current[key], list) and index < len(current[key]):
|
if isinstance(current[key], list) and index < len(current[key]):
|
||||||
current = current[key][index]
|
current = current[key][index]
|
||||||
else:
|
else:
|
||||||
|
logger.debug(f"Path {part} invalid: key={key}, index={index}, current={current.get(key)}")
|
||||||
return None
|
return None
|
||||||
|
elif isinstance(current, list) and index < len(current):
|
||||||
|
current = current[index]
|
||||||
else:
|
else:
|
||||||
|
logger.debug(f"Path {part} not found in current={current}")
|
||||||
return None
|
return None
|
||||||
else:
|
else:
|
||||||
if isinstance(current, dict) and part in current:
|
# Handle choice types (e.g., onset[x])
|
||||||
current = current[part]
|
if '[x]' in part:
|
||||||
else:
|
part = part.replace('[x]', '')
|
||||||
return None
|
# Try common choice type suffixes
|
||||||
|
for suffix in ['', 'DateTime', 'Age', 'Period', 'Range', 'String', 'CodeableConcept']:
|
||||||
|
test_key = part + suffix
|
||||||
|
if isinstance(current, dict) and test_key in current:
|
||||||
|
current = current[test_key]
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
logger.debug(f"Choice type {part}[x] not found in current={current}")
|
||||||
|
return None
|
||||||
|
elif isinstance(current, dict):
|
||||||
|
if part in current:
|
||||||
|
current = current[part]
|
||||||
|
else:
|
||||||
|
# Handle FHIR complex types
|
||||||
|
if part == 'code' and 'coding' in current and isinstance(current['coding'], list) and current['coding']:
|
||||||
|
current = current['coding']
|
||||||
|
elif part == 'patient' and 'reference' in current and current['reference']:
|
||||||
|
current = current['reference']
|
||||||
|
elif part == 'manifestation' and isinstance(current, list) and current and 'coding' in current[0] and current[0]['coding']:
|
||||||
|
current = current[0]['coding']
|
||||||
|
elif part == 'clinicalStatus' and 'coding' in current and isinstance(current['coding'], list) and current['coding']:
|
||||||
|
current = current['coding']
|
||||||
|
else:
|
||||||
|
logger.debug(f"Path {part} not found in current={current}")
|
||||||
|
return None
|
||||||
|
elif isinstance(current, list) and len(current) > 0:
|
||||||
|
# Try to find the part in list items
|
||||||
|
found = False
|
||||||
|
for item in current:
|
||||||
|
if isinstance(item, dict) and part in item:
|
||||||
|
current = item[part]
|
||||||
|
found = True
|
||||||
|
break
|
||||||
|
if not found:
|
||||||
|
# For nested paths like communication.language, return None only if the parent is absent
|
||||||
|
logger.debug(f"Path {part} not found in list items: {current}")
|
||||||
|
return None
|
||||||
if extension_url and isinstance(current, list):
|
if extension_url and isinstance(current, list):
|
||||||
current = [item for item in current if item.get('url') == extension_url]
|
current = [item for item in current if item.get('url') == extension_url]
|
||||||
return current
|
# Return non-None/non-empty values as present
|
||||||
|
result = current if (current is not None and (not isinstance(current, list) or current)) else None
|
||||||
|
logger.debug(f"Path {path} resolved to: {result}")
|
||||||
|
return result
|
||||||
|
|
||||||
def validate_resource_against_profile(package_name, version, resource, include_dependencies=True):
|
def validate_resource_against_profile(package_name, version, resource, include_dependencies=True):
|
||||||
"""Validates a FHIR resource against a StructureDefinition in the specified package."""
|
"""Validates a FHIR resource against a StructureDefinition in the specified package."""
|
||||||
@ -593,29 +649,93 @@ def validate_resource_against_profile(package_name, version, resource, include_d
|
|||||||
if not download_dir:
|
if not download_dir:
|
||||||
result['valid'] = False
|
result['valid'] = False
|
||||||
result['errors'].append("Could not access download directory")
|
result['errors'].append("Could not access download directory")
|
||||||
|
logger.error("Validation failed: Could not access download directory")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
tgz_path = os.path.join(download_dir, construct_tgz_filename(package_name, version))
|
tgz_path = os.path.join(download_dir, construct_tgz_filename(package_name, version))
|
||||||
sd_data, _ = find_and_extract_sd(tgz_path, resource.get('resourceType'))
|
logger.debug(f"Checking for package file: {tgz_path}")
|
||||||
if not sd_data:
|
if not os.path.exists(tgz_path):
|
||||||
result['valid'] = False
|
result['valid'] = False
|
||||||
result['errors'].append(f"No StructureDefinition found for {resource.get('resourceType')} in {package_name}#{version}")
|
result['errors'].append(f"Package file not found: {package_name}#{version}")
|
||||||
|
logger.error(f"Validation failed: Package file not found at {tgz_path}")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# Basic validation: check required elements and Must Support
|
# Use profile from meta.profile if available
|
||||||
|
profile_url = None
|
||||||
|
meta = resource.get('meta', {})
|
||||||
|
profiles = meta.get('profile', [])
|
||||||
|
if profiles:
|
||||||
|
profile_url = profiles[0] # Use first profile
|
||||||
|
logger.debug(f"Using profile from meta.profile: {profile_url}")
|
||||||
|
|
||||||
|
sd_data, sd_path = find_and_extract_sd(tgz_path, resource.get('resourceType'), profile_url)
|
||||||
|
if not sd_data:
|
||||||
|
result['valid'] = False
|
||||||
|
result['errors'].append(f"No StructureDefinition found for {resource.get('resourceType')} with profile {profile_url or 'any'} in {package_name}#{version}")
|
||||||
|
logger.error(f"Validation failed: No SD for {resource.get('resourceType')} in {tgz_path}")
|
||||||
|
return result
|
||||||
|
logger.debug(f"Found SD at {sd_path}")
|
||||||
|
|
||||||
|
# Validate required elements (min=1)
|
||||||
|
errors = []
|
||||||
elements = sd_data.get('snapshot', {}).get('element', [])
|
elements = sd_data.get('snapshot', {}).get('element', [])
|
||||||
for element in elements:
|
for element in elements:
|
||||||
path = element.get('path')
|
path = element.get('path')
|
||||||
if element.get('min', 0) > 0:
|
min_val = element.get('min', 0)
|
||||||
|
# Only check top-level required elements
|
||||||
|
if min_val > 0 and not '.' in path[1 + path.find('.'):]:
|
||||||
value = navigate_fhir_path(resource, path)
|
value = navigate_fhir_path(resource, path)
|
||||||
if value is None or (isinstance(value, list) and not value):
|
if value is None or (isinstance(value, list) and not any(value)):
|
||||||
result['valid'] = False
|
errors.append(f"Required element {path} missing")
|
||||||
result['errors'].append(f"Required element {path} missing")
|
logger.info(f"Validation error: Required element {path} missing")
|
||||||
if element.get('mustSupport', False):
|
|
||||||
value = navigate_fhir_path(resource, path)
|
|
||||||
if value is None or (isinstance(value, list) and not value):
|
|
||||||
result['warnings'].append(f"Must Support element {path} missing or empty")
|
|
||||||
|
|
||||||
|
# Validate must-support elements
|
||||||
|
warnings = []
|
||||||
|
for element in elements:
|
||||||
|
path = element.get('path')
|
||||||
|
# Only check top-level must-support elements
|
||||||
|
if element.get('mustSupport', False) and not '.' in path[1 + path.find('.'):]:
|
||||||
|
# Handle choice types (e.g., value[x], effective[x])
|
||||||
|
if '[x]' in path:
|
||||||
|
base_path = path.replace('[x]', '')
|
||||||
|
found = False
|
||||||
|
# Try common choice type suffixes
|
||||||
|
for suffix in ['Quantity', 'CodeableConcept', 'String', 'DateTime', 'Period', 'Range']:
|
||||||
|
test_path = f"{base_path}{suffix}"
|
||||||
|
value = navigate_fhir_path(resource, test_path)
|
||||||
|
if value is not None and (not isinstance(value, list) or any(value)):
|
||||||
|
found = True
|
||||||
|
break
|
||||||
|
if not found:
|
||||||
|
warnings.append(f"Must Support element {path} missing or empty")
|
||||||
|
logger.info(f"Validation warning: Must Support element {path} missing or empty")
|
||||||
|
else:
|
||||||
|
value = navigate_fhir_path(resource, path)
|
||||||
|
if value is None or (isinstance(value, list) and not any(value)):
|
||||||
|
# Only warn for non-required must-support elements
|
||||||
|
if element.get('min', 0) == 0:
|
||||||
|
warnings.append(f"Must Support element {path} missing or empty")
|
||||||
|
logger.info(f"Validation warning: Must Support element {path} missing or empty")
|
||||||
|
# Handle dataAbsentReason only if value[x] is absent
|
||||||
|
if path.endswith('dataAbsentReason') and element.get('mustSupport', False):
|
||||||
|
value_x_path = path.replace('dataAbsentReason', 'value[x]')
|
||||||
|
value_found = False
|
||||||
|
for suffix in ['Quantity', 'CodeableConcept', 'String', 'DateTime', 'Period', 'Range']:
|
||||||
|
test_path = path.replace('dataAbsentReason', f'value{suffix}')
|
||||||
|
value = navigate_fhir_path(resource, test_path)
|
||||||
|
if value is not None and (not isinstance(value, list) or any(value)):
|
||||||
|
value_found = True
|
||||||
|
break
|
||||||
|
if not value_found:
|
||||||
|
value = navigate_fhir_path(resource, path)
|
||||||
|
if value is None or (isinstance(value, list) and not any(value)):
|
||||||
|
warnings.append(f"Must Support element {path} missing or empty")
|
||||||
|
logger.info(f"Validation warning: Must Support element {path} missing or empty")
|
||||||
|
|
||||||
|
result['valid'] = len(errors) == 0
|
||||||
|
result['errors'] = errors
|
||||||
|
result['warnings'] = warnings
|
||||||
|
logger.debug(f"Validation result: valid={result['valid']}, errors={result['errors']}, warnings={result['warnings']}")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def validate_bundle_against_profile(package_name, version, bundle, include_dependencies=True):
|
def validate_bundle_against_profile(package_name, version, bundle, include_dependencies=True):
|
||||||
@ -630,6 +750,7 @@ def validate_bundle_against_profile(package_name, version, bundle, include_depen
|
|||||||
if not bundle.get('resourceType') == 'Bundle':
|
if not bundle.get('resourceType') == 'Bundle':
|
||||||
result['valid'] = False
|
result['valid'] = False
|
||||||
result['errors'].append("Resource is not a Bundle")
|
result['errors'].append("Resource is not a Bundle")
|
||||||
|
logger.error("Validation failed: Resource is not a Bundle")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
for entry in bundle.get('entry', []):
|
for entry in bundle.get('entry', []):
|
||||||
@ -645,6 +766,7 @@ def validate_bundle_against_profile(package_name, version, bundle, include_depen
|
|||||||
result['errors'].extend(validation_result['errors'])
|
result['errors'].extend(validation_result['errors'])
|
||||||
result['warnings'].extend(validation_result['warnings'])
|
result['warnings'].extend(validation_result['warnings'])
|
||||||
|
|
||||||
|
logger.debug(f"Bundle validation result: valid={result['valid']}, errors={len(result['errors'])}, warnings={len(result['warnings'])}")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# --- Structure Definition Retrieval ---
|
# --- Structure Definition Retrieval ---
|
||||||
@ -1030,6 +1152,97 @@ def import_package_and_dependencies(initial_name, initial_version, dependency_mo
|
|||||||
logger.info(f"Import finished for {initial_name}#{initial_version}. Processed: {len(results['processed'])}, Downloaded: {len(results['downloaded'])}, Errors: {len(results['errors'])}")
|
logger.info(f"Import finished for {initial_name}#{initial_version}. Processed: {len(results['processed'])}, Downloaded: {len(results['downloaded'])}, Errors: {len(results['errors'])}")
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
# --- Validation Route ---
|
||||||
|
@services_bp.route('/validate-sample', methods=['POST'])
|
||||||
|
def validate_sample():
|
||||||
|
"""Validates a FHIR sample against a package profile."""
|
||||||
|
logger.debug("Received validate-sample request")
|
||||||
|
data = request.get_json(silent=True)
|
||||||
|
if not data:
|
||||||
|
logger.error("No JSON data provided or invalid JSON in validate-sample request")
|
||||||
|
return jsonify({
|
||||||
|
'valid': False,
|
||||||
|
'errors': ["No JSON data provided or invalid JSON"],
|
||||||
|
'warnings': [],
|
||||||
|
'results': {}
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
package_name = data.get('package_name')
|
||||||
|
version = data.get('version')
|
||||||
|
sample_data = data.get('sample_data')
|
||||||
|
|
||||||
|
logger.debug(f"Request params: package_name={package_name}, version={version}, sample_data_length={len(sample_data) if sample_data else 0}")
|
||||||
|
if not package_name or not version or not sample_data:
|
||||||
|
logger.error(f"Missing required fields: package_name={package_name}, version={version}, sample_data={'provided' if sample_data else 'missing'}")
|
||||||
|
return jsonify({
|
||||||
|
'valid': False,
|
||||||
|
'errors': ["Missing required fields: package_name, version, or sample_data"],
|
||||||
|
'warnings': [],
|
||||||
|
'results': {}
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Verify download directory access
|
||||||
|
download_dir = _get_download_dir()
|
||||||
|
if not download_dir:
|
||||||
|
logger.error("Cannot access download directory")
|
||||||
|
return jsonify({
|
||||||
|
'valid': False,
|
||||||
|
'errors': ["Server configuration error: cannot access package directory"],
|
||||||
|
'warnings': [],
|
||||||
|
'results': {}
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
# Verify package file exists
|
||||||
|
tgz_filename = construct_tgz_filename(package_name, version)
|
||||||
|
tgz_path = os.path.join(download_dir, tgz_filename)
|
||||||
|
logger.debug(f"Checking package file: {tgz_path}")
|
||||||
|
if not os.path.exists(tgz_path):
|
||||||
|
logger.error(f"Package file not found: {tgz_path}")
|
||||||
|
return jsonify({
|
||||||
|
'valid': False,
|
||||||
|
'errors': [f"Package not found: {package_name}#{version}. Please import the package first."],
|
||||||
|
'warnings': [],
|
||||||
|
'results': {}
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Parse JSON sample
|
||||||
|
sample = json.loads(sample_data)
|
||||||
|
resource_type = sample.get('resourceType')
|
||||||
|
if not resource_type:
|
||||||
|
logger.error("Sample JSON missing resourceType")
|
||||||
|
return jsonify({
|
||||||
|
'valid': False,
|
||||||
|
'errors': ["Sample JSON missing resourceType"],
|
||||||
|
'warnings': [],
|
||||||
|
'results': {}
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
logger.debug(f"Validating {resource_type} against {package_name}#{version}")
|
||||||
|
# Validate resource or bundle
|
||||||
|
if resource_type == 'Bundle':
|
||||||
|
result = validate_bundle_against_profile(package_name, version, sample)
|
||||||
|
else:
|
||||||
|
result = validate_resource_against_profile(package_name, version, sample)
|
||||||
|
|
||||||
|
logger.info(f"Validation result for {resource_type} against {package_name}#{version}: valid={result['valid']}, errors={len(result['errors'])}, warnings={len(result['warnings'])}")
|
||||||
|
return jsonify(result)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.error(f"Invalid JSON in sample_data: {e}")
|
||||||
|
return jsonify({
|
||||||
|
'valid': False,
|
||||||
|
'errors': [f"Invalid JSON: {str(e)}"],
|
||||||
|
'warnings': [],
|
||||||
|
'results': {}
|
||||||
|
}), 400
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Validation failed: {e}", exc_info=True)
|
||||||
|
return jsonify({
|
||||||
|
'valid': False,
|
||||||
|
'errors': [f"Validation failed: {str(e)}"],
|
||||||
|
'warnings': [],
|
||||||
|
'results': {}
|
||||||
|
}), 500
|
||||||
# --- Standalone Test ---
|
# --- Standalone Test ---
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
logger.info("Running services.py directly for testing.")
|
logger.info("Running services.py directly for testing.")
|
||||||
@ -1082,4 +1295,5 @@ if __name__ == '__main__':
|
|||||||
print(f" Imposed Profiles: {processing_results.get('imposed_profiles', [])}")
|
print(f" Imposed Profiles: {processing_results.get('imposed_profiles', [])}")
|
||||||
print(f" Processing Errors: {processing_results.get('errors', [])}")
|
print(f" Processing Errors: {processing_results.get('errors', [])}")
|
||||||
else:
|
else:
|
||||||
print(f"\n--- Skipping Processing Test (Import failed for {pkg_name_to_test}#{pkg_version_to_test}) ---")
|
print(f"\n--- Skipping Processing Test (Import failed for {pkg_name_to_test}#{pkg_version_to_test}) ---")
|
||||||
|
|
||||||
|
842
templates/cp_view_processed_ig.html
Normal file
842
templates/cp_view_processed_ig.html
Normal file
@ -0,0 +1,842 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="px-4 py-5 my-5 text-center">
|
||||||
|
<img class="d-block mx-auto mb-4" src="{{ url_for('static', filename='FHIRFLARE.png') }}" alt="FHIRFLARE Ig Toolkit" width="192" height="192">
|
||||||
|
<h1 class="display-5 fw-bold text-body-emphasis">{{ title }}</h1>
|
||||||
|
<div class="col-lg-6 mx-auto">
|
||||||
|
<p class="lead mb-4">
|
||||||
|
View details of the processed FHIR Implementation Guide.
|
||||||
|
</p>
|
||||||
|
<div class="d-grid gap-2 d-sm-flex justify-content-sm-center">
|
||||||
|
<a href="{{ url_for('index') }}" class="btn btn-primary btn-lg px-4 gap-3">Back to Home</a>
|
||||||
|
<a href="{{ url_for('view_igs') }}" class="btn btn-outline-secondary btn-lg px-4">Manage FHIR Packages</a>
|
||||||
|
<a href="{{ url_for('push_igs') }}" class="btn btn-outline-secondary btn-lg px-4">Upload IG's</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container mt-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h2><i class="bi bi-info-circle me-2"></i>{{ title }}</h2>
|
||||||
|
<a href="{{ url_for('view_igs') }}" class="btn btn-secondary"><i class="bi bi-arrow-left"></i> Back to Package List</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if processed_ig %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">Package Details</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<dl class="row">
|
||||||
|
<dt class="col-sm-3">Package Name</dt>
|
||||||
|
<dd class="col-sm-9"><code>{{ processed_ig.package_name }}</code></dd>
|
||||||
|
<dt class="col-sm-3">Package Version</dt>
|
||||||
|
<dd class="col-sm-9">{{ processed_ig.version }}</dd>
|
||||||
|
<dt class="col-sm-3">Processed At</dt>
|
||||||
|
<dd class="col-sm-9">{{ processed_ig.processed_date.strftime('%Y-%m-%d %H:%M:%S UTC') }}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if config.DISPLAY_PROFILE_RELATIONSHIPS %}
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="card-header">Profile Relationships</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h6>Complies With</h6>
|
||||||
|
{% if complies_with_profiles %}
|
||||||
|
<ul>
|
||||||
|
{% for profile in complies_with_profiles %}
|
||||||
|
<li>{{ profile }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted"><em>No profiles declared as compatible.</em></p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<h6>Required Dependent Profiles (Must Also Validate Against)</h6>
|
||||||
|
{% if imposed_profiles %}
|
||||||
|
<ul>
|
||||||
|
{% for profile in imposed_profiles %}
|
||||||
|
<li>{{ profile }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted"><em>No imposed profiles.</em></p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="card-header">Resource Types Found / Defined</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if profile_list or base_list %}
|
||||||
|
<p class="mb-2">
|
||||||
|
<small>
|
||||||
|
<span class="badge bg-warning text-dark border me-1" style="font-size: 0.8em;" title="Profile contains Must Support elements">MS</span> = Contains Must Support Elements<br>
|
||||||
|
<span class="badge bg-info text-dark border me-1" style="font-size: 0.8em;" title="An optional Extension which contains Must Support sub-elements (e.g., value[x], type)">Optional MS Ext</span> = Optional Extension with Must Support Sub-Elements
|
||||||
|
</small>
|
||||||
|
</p>
|
||||||
|
{% if profile_list %}
|
||||||
|
<p class="mb-2"><small><span class="badge bg-primary text-light border me-1">Examples</span> = Examples will be displayed when selecting profile Types if contained in the IG</small></p>
|
||||||
|
<h6>Profiles Defined ({{ profile_list|length }}):</h6>
|
||||||
|
<div class="d-flex flex-wrap gap-1 mb-3 resource-type-list">
|
||||||
|
{% for type_info in profile_list %}
|
||||||
|
<a href="#" class="resource-type-link text-decoration-none"
|
||||||
|
data-package-name="{{ processed_ig.package_name }}"
|
||||||
|
data-package-version="{{ processed_ig.version }}"
|
||||||
|
data-resource-type="{{ type_info.name }}"
|
||||||
|
aria-label="View structure for {{ type_info.name }}">
|
||||||
|
{% if type_info.must_support %}
|
||||||
|
{% if optional_usage_elements.get(type_info.name) %}
|
||||||
|
<span class="badge bg-info text-dark border" title="Optional Extension with Must Support sub-elements">{{ type_info.name }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-warning text-dark border" title="Contains Must Support elements">{{ type_info.name }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-light text-dark border">{{ type_info.name }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted"><em>No profiles defined.</em></p>
|
||||||
|
{% endif %}
|
||||||
|
{% if base_list %}
|
||||||
|
<h6>Base Resource Types Referenced ({{ base_list|length }}):</h6>
|
||||||
|
<div class="d-flex flex-wrap gap-1 resource-type-list">
|
||||||
|
{% for type_info in base_list %}
|
||||||
|
<a href="#" class="resource-type-link text-decoration-none"
|
||||||
|
data-package-name="{{ processed_ig.package_name }}"
|
||||||
|
data-package-version="{{ processed_ig.version }}"
|
||||||
|
data-resource-type="{{ type_info.name }}"
|
||||||
|
aria-label="View structure for {{ type_info.name }}">
|
||||||
|
{% if type_info.must_support %}
|
||||||
|
<span class="badge bg-warning text-dark border" title="Contains Must Support elements">{{ type_info.name }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-light text-dark border">{{ type_info.name }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted"><em>No base resource types referenced.</em></p>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted"><em>No resource type information extracted or stored.</em></p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="structure-display-wrapper" class="mt-4" style="display: none;">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<span>Structure Definition for: <code id="structure-title"></code></span>
|
||||||
|
<button type="button" class="btn-close" aria-label="Close" onclick="document.getElementById('structure-display-wrapper').style.display='none'; document.getElementById('example-display-wrapper').style.display='none'; document.getElementById('raw-structure-wrapper').style.display='none';"></button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="structure-loading" class="text-center py-3" style="display: none;">
|
||||||
|
<div class="spinner-border text-primary" role="status"><span class="visually-hidden">Loading...</span></div>
|
||||||
|
</div>
|
||||||
|
<div id="structure-fallback-message" class="alert alert-info" style="display: none;"></div>
|
||||||
|
<div id="structure-content"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="raw-structure-wrapper" class="mt-4" style="display: none;">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">Raw Structure Definition for <code id="raw-structure-title"></code></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary" id="copy-json-button"
|
||||||
|
data-bs-toggle="tooltip" data-bs-placement="top" title="Copy JSON">
|
||||||
|
<i class="bi bi-clipboard" aria-hidden="true"></i>
|
||||||
|
<span class="visually-hidden">Copy JSON to clipboard</span>
|
||||||
|
</button>
|
||||||
|
<div id="raw-structure-loading" class="text-center py-3" style="display: none;">
|
||||||
|
<div class="spinner-border text-primary" role="status"><span class="visually-hidden">Loading...</span></div>
|
||||||
|
</div>
|
||||||
|
<pre><code id="raw-structure-content" class="p-2 d-block border bg-light" style="max-height: 400px; overflow-y: auto;"></code></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="example-display-wrapper" class="mt-4" style="display: none;">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">Examples for <code id="example-resource-type-title"></code></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mb-3" id="example-selector-wrapper" style="display: none;">
|
||||||
|
<label for="example-select" class="form-label">Select Example:</label>
|
||||||
|
<select class="form-select form-select-sm" id="example-select"><option selected value="">-- Select --</option></select>
|
||||||
|
</div>
|
||||||
|
<div id="example-loading" class="text-center py-3" style="display: none;">
|
||||||
|
<div class="spinner-border spinner-border-sm text-secondary" role="status"><span class="visually-hidden">Loading...</span></div>
|
||||||
|
</div>
|
||||||
|
<div id="example-content-wrapper" style="display: none;">
|
||||||
|
<h6 id="example-filename" class="mt-2 small text-muted"></h6>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h6>Raw Content</h6>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary" id="copy-json-button"
|
||||||
|
data-bs-toggle="tooltip" data-bs-placement="top" title="Copy JSON">
|
||||||
|
<i class="bi bi-clipboard" aria-hidden="true"></i>
|
||||||
|
<span class="visually-hidden">Copy JSON to clipboard</span>
|
||||||
|
</button>
|
||||||
|
<pre><code id="example-content-raw" class="p-2 d-block border bg-light" style="max-height: 400px; overflow-y: auto;"></code></pre>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||||
|
<h6>Pretty-Printed JSON</h6>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary" id="copy-json-button"
|
||||||
|
data-bs-toggle="tooltip" data-bs-placement="top" title="Copy JSON">
|
||||||
|
<i class="bi bi-clipboard" aria-hidden="true"></i>
|
||||||
|
<span class="visually-hidden">Copy JSON to clipboard</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre><code id="example-content-json" class="p-2 d-block border bg-light" style="max-height: 400px; overflow-y: auto;"></code></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-warning" role="alert">Processed IG details not available.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script>const examplesData = {{ examples_by_type | tojson | safe }};</script>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
try {
|
||||||
|
// Existing element constants...
|
||||||
|
const structureDisplayWrapper = document.getElementById('structure-display-wrapper');
|
||||||
|
const structureDisplay = document.getElementById('structure-content');
|
||||||
|
const structureTitle = document.getElementById('structure-title');
|
||||||
|
const structureLoading = document.getElementById('structure-loading');
|
||||||
|
const structureFallbackMessage = document.getElementById('structure-fallback-message');
|
||||||
|
const exampleDisplayWrapper = document.getElementById('example-display-wrapper');
|
||||||
|
const exampleSelectorWrapper = document.getElementById('example-selector-wrapper');
|
||||||
|
const exampleSelect = document.getElementById('example-select');
|
||||||
|
const exampleResourceTypeTitle = document.getElementById('example-resource-type-title');
|
||||||
|
const exampleLoading = document.getElementById('example-loading');
|
||||||
|
const exampleContentWrapper = document.getElementById('example-content-wrapper');
|
||||||
|
const exampleFilename = document.getElementById('example-filename');
|
||||||
|
const exampleContentRaw = document.getElementById('example-content-raw');
|
||||||
|
const exampleContentJson = document.getElementById('example-content-json'); // Already exists
|
||||||
|
const rawStructureWrapper = document.getElementById('raw-structure-wrapper');
|
||||||
|
const rawStructureTitle = document.getElementById('raw-structure-title');
|
||||||
|
const rawStructureContent = document.getElementById('raw-structure-content');
|
||||||
|
const rawStructureLoading = document.getElementById('raw-structure-loading');
|
||||||
|
|
||||||
|
// Base URLs...
|
||||||
|
const structureBaseUrl = "{{ url_for('get_structure_definition') }}";
|
||||||
|
const exampleBaseUrl = "{{ url_for('get_example_content') }}";
|
||||||
|
|
||||||
|
// Existing Structure definition fetching and display logic...
|
||||||
|
document.querySelectorAll('.resource-type-list').forEach(container => {
|
||||||
|
container.addEventListener('click', function(event) {
|
||||||
|
const link = event.target.closest('.resource-type-link');
|
||||||
|
if (!link) return;
|
||||||
|
event.preventDefault();
|
||||||
|
console.log("Resource type link clicked:", link.dataset.resourceType);
|
||||||
|
|
||||||
|
const pkgName = link.dataset.packageName;
|
||||||
|
const pkgVersion = link.dataset.packageVersion;
|
||||||
|
const resourceType = link.dataset.resourceType;
|
||||||
|
if (!pkgName || !pkgVersion || !resourceType) {
|
||||||
|
console.error("Missing data attributes:", { pkgName, pkgVersion, resourceType });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const structureParams = new URLSearchParams({ package_name: pkgName, package_version: pkgVersion, resource_type: resourceType });
|
||||||
|
const structureFetchUrl = `${structureBaseUrl}?${structureParams.toString()}`;
|
||||||
|
console.log("Fetching structure from:", structureFetchUrl);
|
||||||
|
|
||||||
|
structureTitle.textContent = `${resourceType} (${pkgName}#${pkgVersion})`;
|
||||||
|
rawStructureTitle.textContent = `${resourceType} (${pkgName}#${pkgVersion})`;
|
||||||
|
structureDisplay.innerHTML = '';
|
||||||
|
rawStructureContent.textContent = '';
|
||||||
|
structureLoading.style.display = 'block';
|
||||||
|
rawStructureLoading.style.display = 'block';
|
||||||
|
structureFallbackMessage.style.display = 'none';
|
||||||
|
structureDisplayWrapper.style.display = 'block';
|
||||||
|
rawStructureWrapper.style.display = 'block';
|
||||||
|
exampleDisplayWrapper.style.display = 'none'; // Hide examples initially
|
||||||
|
|
||||||
|
fetch(structureFetchUrl)
|
||||||
|
.then(response => {
|
||||||
|
console.log("Structure fetch response status:", response.status);
|
||||||
|
// Use response.json() directly, check ok status in the next .then()
|
||||||
|
if (!response.ok) {
|
||||||
|
// Attempt to parse error JSON first
|
||||||
|
return response.json().catch(() => null).then(errorData => {
|
||||||
|
throw new Error(errorData?.error || `HTTP error ${response.status}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(data => { // No longer destructuring result here, just using data
|
||||||
|
console.log("Structure data received:", data);
|
||||||
|
if (data.fallback_used) {
|
||||||
|
structureFallbackMessage.style.display = 'block';
|
||||||
|
structureFallbackMessage.textContent = `Structure definition not found in this IG; using base FHIR definition from ${data.source_package}.`;
|
||||||
|
} else {
|
||||||
|
structureFallbackMessage.style.display = 'none';
|
||||||
|
}
|
||||||
|
renderStructureTree(data.elements, data.must_support_paths || [], resourceType);
|
||||||
|
rawStructureContent.textContent = JSON.stringify(data, null, 2); // Changed result.data to data
|
||||||
|
populateExampleSelector(resourceType); // Show/hide example section based on availability
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error("Error fetching/processing structure:", error);
|
||||||
|
structureFallbackMessage.style.display = 'none';
|
||||||
|
structureDisplay.innerHTML = `<div class="alert alert-danger">Error loading structure: ${error.message}</div>`;
|
||||||
|
rawStructureContent.textContent = `Error loading structure: ${error.message}`;
|
||||||
|
// Still try to populate examples, might be useful even if structure fails? Or hide it?
|
||||||
|
// Let's hide it if structure fails completely
|
||||||
|
exampleDisplayWrapper.style.display = 'none';
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
structureLoading.style.display = 'none';
|
||||||
|
rawStructureLoading.style.display = 'none';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Existing function populateExampleSelector...
|
||||||
|
function populateExampleSelector(resourceOrProfileIdentifier) {
|
||||||
|
console.log("Populating examples for:", resourceOrProfileIdentifier);
|
||||||
|
exampleResourceTypeTitle.textContent = resourceOrProfileIdentifier;
|
||||||
|
const availableExamples = examplesData[resourceOrProfileIdentifier] || [];
|
||||||
|
console.log("Available examples:", availableExamples);
|
||||||
|
exampleSelect.innerHTML = '<option selected value="">-- Select --</option>';
|
||||||
|
exampleContentWrapper.style.display = 'none';
|
||||||
|
exampleFilename.textContent = '';
|
||||||
|
exampleContentRaw.textContent = '';
|
||||||
|
exampleContentJson.textContent = '';
|
||||||
|
|
||||||
|
if (availableExamples.length > 0) {
|
||||||
|
availableExamples.forEach(filePath => {
|
||||||
|
const filename = filePath.split('/').pop();
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = filePath;
|
||||||
|
option.textContent = filename;
|
||||||
|
exampleSelect.appendChild(option);
|
||||||
|
});
|
||||||
|
exampleSelectorWrapper.style.display = 'block';
|
||||||
|
exampleDisplayWrapper.style.display = 'block'; // Show the whole example section
|
||||||
|
} else {
|
||||||
|
exampleSelectorWrapper.style.display = 'none';
|
||||||
|
exampleDisplayWrapper.style.display = 'none'; // Hide the whole example section if none available
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Existing event listener for exampleSelect...
|
||||||
|
if (exampleSelect) {
|
||||||
|
exampleSelect.addEventListener('change', function(event) {
|
||||||
|
const selectedFilePath = this.value;
|
||||||
|
console.log("Selected example file:", selectedFilePath);
|
||||||
|
exampleContentRaw.textContent = '';
|
||||||
|
exampleContentJson.textContent = '';
|
||||||
|
exampleFilename.textContent = '';
|
||||||
|
exampleContentWrapper.style.display = 'none';
|
||||||
|
|
||||||
|
if (!selectedFilePath) return;
|
||||||
|
|
||||||
|
const exampleParams = new URLSearchParams({
|
||||||
|
package_name: "{{ processed_ig.package_name }}",
|
||||||
|
package_version: "{{ processed_ig.version }}",
|
||||||
|
filename: selectedFilePath
|
||||||
|
});
|
||||||
|
const exampleFetchUrl = `${exampleBaseUrl}?${exampleParams.toString()}`;
|
||||||
|
console.log("Fetching example from:", exampleFetchUrl);
|
||||||
|
exampleLoading.style.display = 'block';
|
||||||
|
|
||||||
|
fetch(exampleFetchUrl)
|
||||||
|
.then(response => {
|
||||||
|
console.log("Example fetch response status:", response.status);
|
||||||
|
if (!response.ok) {
|
||||||
|
return response.text().then(errText => { throw new Error(errText || `HTTP error ${response.status}`); });
|
||||||
|
}
|
||||||
|
return response.text();
|
||||||
|
})
|
||||||
|
.then(content => {
|
||||||
|
console.log("Example content received.");
|
||||||
|
exampleFilename.textContent = `Source: ${selectedFilePath.split('/').pop()}`;
|
||||||
|
exampleContentRaw.textContent = content;
|
||||||
|
try {
|
||||||
|
const jsonContent = JSON.parse(content);
|
||||||
|
exampleContentJson.textContent = JSON.stringify(jsonContent, null, 2);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Example content is not valid JSON:", e.message);
|
||||||
|
exampleContentJson.textContent = '(Not valid JSON)';
|
||||||
|
}
|
||||||
|
exampleContentWrapper.style.display = 'block';
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error("Error fetching example:", error);
|
||||||
|
exampleFilename.textContent = 'Error loading example';
|
||||||
|
exampleContentRaw.textContent = `Error: ${error.message}`;
|
||||||
|
exampleContentJson.textContent = `Error: ${error.message}`;
|
||||||
|
exampleContentWrapper.style.display = 'block';
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
exampleLoading.style.display = 'none';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- START: Add Copy Button Logic ---
|
||||||
|
const copyJsonButton = document.getElementById('copy-json-button');
|
||||||
|
// const jsonCodeElement = document.getElementById('example-content-json'); // Already defined above
|
||||||
|
let copyTooltipInstance = null; // To hold the tooltip object
|
||||||
|
|
||||||
|
if (copyJsonButton && exampleContentJson) { // Use exampleContentJson here
|
||||||
|
// Initialize the Bootstrap Tooltip *once*
|
||||||
|
copyTooltipInstance = new bootstrap.Tooltip(copyJsonButton);
|
||||||
|
|
||||||
|
copyJsonButton.addEventListener('click', () => {
|
||||||
|
const textToCopy = exampleContentJson.textContent; // Use the correct element
|
||||||
|
const originalTitle = 'Copy JSON';
|
||||||
|
const successTitle = 'Copied!';
|
||||||
|
const copyIcon = copyJsonButton.querySelector('i'); // Get the icon element
|
||||||
|
|
||||||
|
if (textToCopy && textToCopy.trim() && textToCopy !== '(Not valid JSON)') {
|
||||||
|
navigator.clipboard.writeText(textToCopy).then(() => {
|
||||||
|
console.log('JSON copied to clipboard');
|
||||||
|
|
||||||
|
// Update tooltip to show success
|
||||||
|
copyJsonButton.setAttribute('data-bs-original-title', successTitle);
|
||||||
|
copyTooltipInstance.show();
|
||||||
|
|
||||||
|
// Change icon to checkmark
|
||||||
|
if (copyIcon) copyIcon.classList.replace('bi-clipboard', 'bi-check-lg');
|
||||||
|
|
||||||
|
// Reset tooltip and icon after a delay
|
||||||
|
setTimeout(() => {
|
||||||
|
copyTooltipInstance.hide();
|
||||||
|
// Check if the tooltip still exists before resetting title
|
||||||
|
if(copyJsonButton.isConnected) {
|
||||||
|
copyJsonButton.setAttribute('data-bs-original-title', originalTitle);
|
||||||
|
if (copyIcon) copyIcon.classList.replace('bi-check-lg', 'bi-clipboard');
|
||||||
|
}
|
||||||
|
}, 2000); // Reset after 2 seconds
|
||||||
|
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('Failed to copy JSON: ', err);
|
||||||
|
// Optional: Show error feedback via tooltip
|
||||||
|
copyJsonButton.setAttribute('data-bs-original-title', 'Copy Failed!');
|
||||||
|
copyTooltipInstance.show();
|
||||||
|
setTimeout(() => {
|
||||||
|
// Check if the tooltip still exists before resetting title
|
||||||
|
if(copyJsonButton.isConnected) {
|
||||||
|
copyTooltipInstance.hide();
|
||||||
|
copyJsonButton.setAttribute('data-bs-original-title', originalTitle);
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log('No valid JSON content to copy.');
|
||||||
|
// Optional: Show feedback if there's nothing to copy
|
||||||
|
copyJsonButton.setAttribute('data-bs-original-title', 'Nothing to copy');
|
||||||
|
copyTooltipInstance.show();
|
||||||
|
setTimeout(() => {
|
||||||
|
// Check if the tooltip still exists before resetting title
|
||||||
|
if(copyJsonButton.isConnected) {
|
||||||
|
copyTooltipInstance.hide();
|
||||||
|
copyJsonButton.setAttribute('data-bs-original-title', originalTitle);
|
||||||
|
}
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Optional: Hide the tooltip if the user selects a new example
|
||||||
|
// to prevent the "Copied!" message from sticking around incorrectly.
|
||||||
|
if (exampleSelect) {
|
||||||
|
exampleSelect.addEventListener('change', () => {
|
||||||
|
if (copyTooltipInstance) {
|
||||||
|
copyTooltipInstance.hide();
|
||||||
|
// Check if the button still exists before resetting title/icon
|
||||||
|
if(copyJsonButton.isConnected) {
|
||||||
|
copyJsonButton.setAttribute('data-bs-original-title', 'Copy JSON');
|
||||||
|
const copyIcon = copyJsonButton.querySelector('i');
|
||||||
|
if (copyIcon) copyIcon.className = 'bi bi-clipboard'; // Reset icon
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
console.warn("Could not find copy button or JSON code element for copy functionality.");
|
||||||
|
}
|
||||||
|
// --- END: Add Copy Button Logic ---
|
||||||
|
|
||||||
|
|
||||||
|
// Existing tree rendering functions (buildTreeData, renderNodeAsLi, renderStructureTree)...
|
||||||
|
// Make sure these are correctly placed within the DOMContentLoaded scope
|
||||||
|
function buildTreeData(elements) {
|
||||||
|
const treeRoot = { children: {}, element: null, name: 'Root' };
|
||||||
|
const nodeMap = { 'Root': treeRoot };
|
||||||
|
|
||||||
|
console.log("Building tree with elements:", elements.length);
|
||||||
|
elements.forEach((el, index) => {
|
||||||
|
const path = el.path;
|
||||||
|
const id = el.id || null;
|
||||||
|
const sliceName = el.sliceName || null;
|
||||||
|
|
||||||
|
// console.log(`Element ${index}: path=${path}, id=${id}, sliceName=${sliceName}`); // DEBUG
|
||||||
|
|
||||||
|
if (!path) {
|
||||||
|
console.warn(`Skipping element ${index} with no path`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = path.split('.');
|
||||||
|
let currentPath = '';
|
||||||
|
let parentNode = treeRoot;
|
||||||
|
let parentPath = 'Root'; // Keep track of the parent's logical path
|
||||||
|
|
||||||
|
for (let i = 0; i < parts.length; i++) {
|
||||||
|
let part = parts[i];
|
||||||
|
const isChoiceElement = part.endsWith('[x]');
|
||||||
|
const cleanPart = part.replace(/\[\d+\]/g, '').replace('[x]', '');
|
||||||
|
<<<<<<< Updated upstream
|
||||||
|
currentPath = i === 0 ? cleanPart : `${currentPath}.${cleanPart}`;
|
||||||
|
|
||||||
|
let nodeKey = isChoiceElement ? part : cleanPart;
|
||||||
|
if (sliceName && i === parts.length - 1) {
|
||||||
|
=======
|
||||||
|
const currentPathSegment = i === 0 ? cleanPart : `${parentPath}.${cleanPart}`; // Build path segment by segment
|
||||||
|
|
||||||
|
let nodeKey = cleanPart; // Usually the last part of the path segment
|
||||||
|
let nodeId = id ? id : currentPathSegment; // Use element.id if available for map key
|
||||||
|
|
||||||
|
// If this is the last part and it's a slice, use sliceName as the display name (nodeKey)
|
||||||
|
// and element.id as the unique identifier (nodeId) in the map.
|
||||||
|
if (sliceName && i === parts.length - 1 && id) {
|
||||||
|
>>>>>>> Stashed changes
|
||||||
|
nodeKey = sliceName;
|
||||||
|
nodeId = id; // Use the full ID like "Observation.component:systolic.value[x]"
|
||||||
|
} else if (isChoiceElement) {
|
||||||
|
nodeKey = part; // Display 'value[x]' etc.
|
||||||
|
nodeId = currentPathSegment; // Map uses path like 'Observation.value[x]'
|
||||||
|
} else {
|
||||||
|
nodeId = currentPathSegment; // Map uses path like 'Observation.status'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (!nodeMap[nodeId]) {
|
||||||
|
// Find the correct parent node in the map using parentPath
|
||||||
|
parentNode = nodeMap[parentPath] || treeRoot;
|
||||||
|
|
||||||
|
const newNode = {
|
||||||
|
children: {},
|
||||||
|
element: null, // Assign element later
|
||||||
|
name: nodeKey, // Display name (sliceName or path part)
|
||||||
|
path: path, // Store the full original path from element
|
||||||
|
id: id || nodeId // Store the element id or constructed path id
|
||||||
|
};
|
||||||
|
<<<<<<< Updated upstream
|
||||||
|
let parentPath = i === 0 ? 'Root' : parts.slice(0, i).join('.').replace(/\[\d+\]/g, '').replace('[x]', '');
|
||||||
|
parentNode = nodeMap[parentPath] || treeRoot;
|
||||||
|
parentNode.children[nodeKey] = newNode;
|
||||||
|
nodeMap[currentPath] = newNode;
|
||||||
|
console.log(`Created node: path=${currentPath}, name=${nodeKey}`);
|
||||||
|
|
||||||
|
// Add modifierExtension only for DomainResource, BackboneElement, or explicitly defined
|
||||||
|
if (el.path === cleanPart + '.modifierExtension' ||
|
||||||
|
(el.type && el.type.some(t => t.code === 'DomainResource' || t.code === 'BackboneElement'))) {
|
||||||
|
const modExtPath = `${currentPath}.modifierExtension`;
|
||||||
|
const modExtNode = {
|
||||||
|
children: {},
|
||||||
|
element: {
|
||||||
|
path: modExtPath,
|
||||||
|
id: modExtPath,
|
||||||
|
short: 'Extensions that cannot be ignored',
|
||||||
|
definition: 'May be used to represent additional information that modifies the understanding of the element.',
|
||||||
|
min: 0,
|
||||||
|
max: '*',
|
||||||
|
type: [{ code: 'Extension' }],
|
||||||
|
isModifier: true
|
||||||
|
},
|
||||||
|
name: 'modifierExtension',
|
||||||
|
path: modExtPath
|
||||||
|
};
|
||||||
|
newNode.children['modifierExtension'] = modExtNode;
|
||||||
|
nodeMap[modExtPath] = modExtNode;
|
||||||
|
console.log(`Added modifierExtension: path=${modExtPath}`);
|
||||||
|
=======
|
||||||
|
|
||||||
|
// Ensure parent exists before adding child
|
||||||
|
if (parentNode) {
|
||||||
|
parentNode.children[nodeKey] = newNode; // Add to parent's children using display name/key
|
||||||
|
nodeMap[nodeId] = newNode; // Add to map using unique nodeId
|
||||||
|
// console.log(`Created node: nodeId=${nodeId}, name=${nodeKey}, parentPath=${parentPath}`); // DEBUG
|
||||||
|
} else {
|
||||||
|
console.warn(`Parent node not found for path: ${parentPath} when creating ${nodeId}`);
|
||||||
|
>>>>>>> Stashed changes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update parentPath for the next iteration
|
||||||
|
parentPath = nodeId;
|
||||||
|
|
||||||
|
// If this is the last part of the path, assign the element data to the node
|
||||||
|
if (i === parts.length - 1) {
|
||||||
|
const targetNode = nodeMap[nodeId];
|
||||||
|
if (targetNode) {
|
||||||
|
targetNode.element = el; // Assign the full element data
|
||||||
|
// console.log(`Assigned element to node: nodeId=${nodeId}, id=${el.id}, sliceName=${sliceName}`); // DEBUG
|
||||||
|
|
||||||
|
// Handle choice elements ([x]) - This part might need refinement based on how choices are defined
|
||||||
|
// Usually, the StructureDefinition only has one entry for onset[x]
|
||||||
|
// and the types are listed within that element. Rendering specific choice options
|
||||||
|
// might be better handled directly in renderNodeAsLi based on el.type.
|
||||||
|
} else {
|
||||||
|
console.warn(`Target node not found in map for assignment: nodeId=${nodeId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter out any potential root-level nodes that weren't properly parented (shouldn't happen ideally)
|
||||||
|
const treeData = Object.values(treeRoot.children);
|
||||||
|
console.log("Tree data constructed:", treeData.length);
|
||||||
|
return treeData;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function renderNodeAsLi(node, mustSupportPathsSet, resourceType, level = 0) {
|
||||||
|
// Check if node or node.element is null/undefined
|
||||||
|
if (!node || !node.element) {
|
||||||
|
// If node exists but element doesn't, maybe it's a structural node without direct element data?
|
||||||
|
// This can happen if parent nodes were created but no element exactly matched that path.
|
||||||
|
// We might want to render *something* or skip it. Let's skip for now.
|
||||||
|
console.warn("Skipping render for node without element data:", node ? node.name : 'undefined node');
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const el = node.element;
|
||||||
|
const path = el.path || 'N/A'; // Use path from element
|
||||||
|
const id = el.id || node.id || path; // Use element id, then node id, fallback to path
|
||||||
|
const displayName = node.name || path.split('.').pop(); // Use node name (sliceName or part), fallback to last path part
|
||||||
|
const sliceName = el.sliceName || null;
|
||||||
|
const min = el.min !== undefined ? el.min : '';
|
||||||
|
const max = el.max || '';
|
||||||
|
const short = el.short || '';
|
||||||
|
const definition = el.definition || '';
|
||||||
|
|
||||||
|
// console.log(`Rendering node: path=${path}, id=${id}, displayName=${displayName}, sliceName=${sliceName}`); // DEBUG
|
||||||
|
|
||||||
|
// Check must support against element.path and element.id
|
||||||
|
let isMustSupport = mustSupportPathsSet.has(path) || (el.id && mustSupportPathsSet.has(el.id));
|
||||||
|
let isOptional = min == 0 && isMustSupport && el.type && el.type.some(t => t.code === 'Extension'); // Simplified optional check
|
||||||
|
|
||||||
|
const liClass = isMustSupport ? 'list-group-item py-1 px-2 list-group-item-warning' : 'list-group-item py-1 px-2';
|
||||||
|
const mustSupportDisplay = isMustSupport ? `<i class="bi bi-check-circle-fill ${isOptional ? 'text-info' : 'text-warning'} ms-1" title="${isOptional ? 'Optional Must Support Extension' : 'Must Support'}"></i>` : '';
|
||||||
|
const hasChildren = Object.keys(node.children).length > 0;
|
||||||
|
const collapseId = `collapse-${id.replace(/[\.\:\/\[\]\(\)\+\*]/g, '-')}`; // Use unique id for collapse target
|
||||||
|
const padding = level * 20;
|
||||||
|
const pathStyle = `padding-left: ${padding}px; white-space: nowrap;`;
|
||||||
|
|
||||||
|
let typeString = 'N/A';
|
||||||
|
if (el.type && el.type.length > 0) {
|
||||||
|
typeString = el.type.map(t => {
|
||||||
|
let s = t.code || '';
|
||||||
|
let profiles = t.targetProfile || t.profile || [];
|
||||||
|
if (profiles.length > 0) {
|
||||||
|
const targetTypes = profiles.map(p => (p || '').split('/').pop()).filter(Boolean).join(', ');
|
||||||
|
if (targetTypes) {
|
||||||
|
s += `(<span class="text-muted fst-italic" title="${profiles.join('\\n')}">${targetTypes}</span>)`; // Use \n for tooltip line breaks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}).join(' | ');
|
||||||
|
}
|
||||||
|
|
||||||
|
let childrenHtml = '';
|
||||||
|
if (hasChildren) {
|
||||||
|
childrenHtml += `<ul class="collapse list-group list-group-flush" id="${collapseId}">`;
|
||||||
|
// Sort children by their element's path for consistent order
|
||||||
|
Object.values(node.children).sort((a, b) => (a.element?.path ?? a.name).localeCompare(b.element?.path ?? b.name)).forEach(childNode => {
|
||||||
|
childrenHtml += renderNodeAsLi(childNode, mustSupportPathsSet, resourceType, level + 1);
|
||||||
|
});
|
||||||
|
childrenHtml += `</ul>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let itemHtml = `<li class="${liClass}">`;
|
||||||
|
// Add data-bs-parent to potentially make collapses smoother if nested within another collapse
|
||||||
|
itemHtml += `<div class="row gx-2 align-items-center ${hasChildren ? 'collapse-toggle' : ''}" ${hasChildren ? `role="button" data-bs-toggle="collapse" data-bs-target="#${collapseId}" aria-expanded="false" aria-controls="${collapseId}"` : ''}>`;
|
||||||
|
itemHtml += `<div class="col-lg-4 col-md-3 text-truncate" style="${pathStyle}">`;
|
||||||
|
itemHtml += `<span style="display: inline-block; width: 1.2em; text-align: center;">`;
|
||||||
|
if (hasChildren) {
|
||||||
|
// Use Bootstrap's default +/- or chevron icons based on collapsed state
|
||||||
|
itemHtml += `<i class="bi bi-chevron-right small toggle-icon"></i>`; // Default closed state
|
||||||
|
}
|
||||||
|
itemHtml += `</span>`;
|
||||||
|
itemHtml += `<code class="fw-bold ms-1" title="${path}">${displayName}</code>`; // Use displayName
|
||||||
|
itemHtml += `</div>`;
|
||||||
|
itemHtml += `<div class="col-lg-1 col-md-1 text-center text-muted small"><code>${min}..${max}</code></div>`;
|
||||||
|
itemHtml += `<div class="col-lg-3 col-md-3 text-truncate small">${typeString}</div>`;
|
||||||
|
itemHtml += `<div class="col-lg-1 col-md-1 text-center">${mustSupportDisplay}</div>`;
|
||||||
|
|
||||||
|
let descriptionTooltipAttrs = '';
|
||||||
|
if (definition || short) { // Show tooltip if either exists
|
||||||
|
const tooltipContent = definition ? definition : short; // Prefer definition for tooltip
|
||||||
|
// Basic escaping for attributes
|
||||||
|
const escapedContent = tooltipContent
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
.replace(/\n/g, ' '); // Replace newlines for attribute
|
||||||
|
descriptionTooltipAttrs = `data-bs-toggle="tooltip" data-bs-placement="top" title="${escapedContent}"`;
|
||||||
|
}
|
||||||
|
// Display short description, fallback to indication if only definition exists
|
||||||
|
itemHtml += `<div class="col-lg-3 col-md-4 text-muted text-truncate small" ${descriptionTooltipAttrs}>${short || (definition ? '(definition available)' : '')}</div>`;
|
||||||
|
itemHtml += `</div>`; // End row div
|
||||||
|
itemHtml += childrenHtml; // Add children UL (if any)
|
||||||
|
itemHtml += `</li>`; // End li
|
||||||
|
return itemHtml;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function renderStructureTree(elements, mustSupportPaths, resourceType) {
|
||||||
|
if (!elements || elements.length === 0) {
|
||||||
|
structureDisplay.innerHTML = '<p class="text-muted"><em>No elements found in Structure Definition.</em></p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mustSupportPathsSet = new Set(mustSupportPaths);
|
||||||
|
console.log("Must Support Paths Set for Tree:", mustSupportPathsSet);
|
||||||
|
|
||||||
|
let html = '<p class="mb-1"><small>' +
|
||||||
|
'<i class="bi bi-check-circle-fill text-warning ms-1"></i> = Must Support (Yellow Row)<br>' +
|
||||||
|
'<i class="bi bi-check-circle-fill text-info ms-1"></i> = Optional Must Support Extension (Blue Icon)</small></p>';
|
||||||
|
html += `<div class="row gx-2 small fw-bold border-bottom mb-1 d-none d-md-flex"><div class="col-lg-4 col-md-3">Path / Name</div><div class="col-lg-1 col-md-1 text-center">Card.</div><div class="col-lg-3 col-md-3">Type(s)</div><div class="col-lg-1 col-md-1 text-center">Status</div><div class="col-lg-3 col-md-4">Description</div></div>`;
|
||||||
|
html += '<ul class="list-group list-group-flush structure-tree-root">'; // Root UL
|
||||||
|
|
||||||
|
const treeData = buildTreeData(elements);
|
||||||
|
// Sort root nodes by path
|
||||||
|
treeData.sort((a, b) => (a.element?.path ?? a.name).localeCompare(b.element?.path ?? b.name));
|
||||||
|
|
||||||
|
treeData.forEach(rootNode => {
|
||||||
|
html += renderNodeAsLi(rootNode, mustSupportPathsSet, resourceType, 0);
|
||||||
|
});
|
||||||
|
html += '</ul>'; // Close root UL
|
||||||
|
|
||||||
|
structureDisplay.innerHTML = html;
|
||||||
|
|
||||||
|
// Re-initialize tooltips for the newly added content
|
||||||
|
const tooltipTriggerList = [].slice.call(structureDisplay.querySelectorAll('[data-bs-toggle="tooltip"]'));
|
||||||
|
tooltipTriggerList.map(tooltipTriggerEl => {
|
||||||
|
// Check if title is not empty before initializing
|
||||||
|
if (tooltipTriggerEl.getAttribute('title')) {
|
||||||
|
return new bootstrap.Tooltip(tooltipTriggerEl);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- START: Restore Collapse Handling ---
|
||||||
|
let lastClickTime = 0;
|
||||||
|
const debounceDelay = 200; // ms
|
||||||
|
|
||||||
|
// Select the elements that *trigger* the collapse (the div row)
|
||||||
|
structureDisplay.querySelectorAll('.collapse-toggle').forEach(toggleEl => {
|
||||||
|
// Find the actual collapsable element (the <ul>) using the ID derived from the toggle's attribute
|
||||||
|
const collapseId = toggleEl.getAttribute('data-bs-target')?.substring(1); // Get ID like 'collapse-Observation-status'
|
||||||
|
if (!collapseId) {
|
||||||
|
// Fallback if data-bs-target wasn't used, try data-collapse-id if you used that previously
|
||||||
|
const fallbackId = toggleEl.getAttribute('data-collapse-id');
|
||||||
|
if(fallbackId) {
|
||||||
|
collapseId = fallbackId;
|
||||||
|
} else {
|
||||||
|
console.warn("No collapse target ID found for toggle:", toggleEl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const collapseEl = document.getElementById(collapseId);
|
||||||
|
if (!collapseEl) {
|
||||||
|
console.warn("No collapse element found for ID:", collapseId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const toggleIcon = toggleEl.querySelector('.toggle-icon');
|
||||||
|
if (!toggleIcon) {
|
||||||
|
console.warn("No toggle icon found for:", collapseId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Initializing collapse for trigger:", toggleEl, "target:", collapseId);
|
||||||
|
|
||||||
|
// Initialize Bootstrap Collapse instance, but don't automatically toggle via attributes
|
||||||
|
const bsCollapse = new bootstrap.Collapse(collapseEl, {
|
||||||
|
toggle: false // IMPORTANT: Prevent auto-toggling via data attributes
|
||||||
|
});
|
||||||
|
|
||||||
|
// Custom click handler with debounce
|
||||||
|
toggleEl.addEventListener('click', event => {
|
||||||
|
event.preventDefault(); // Prevent default action (like navigating if it was an <a>)
|
||||||
|
event.stopPropagation(); // Stop the event from bubbling up
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastClickTime < debounceDelay) {
|
||||||
|
console.log("Debounced click ignored for:", collapseId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastClickTime = now;
|
||||||
|
|
||||||
|
console.log("Click toggle for:", collapseId, "Current show:", collapseEl.classList.contains('show'));
|
||||||
|
|
||||||
|
// Manually toggle the collapse state
|
||||||
|
if (collapseEl.classList.contains('show')) {
|
||||||
|
bsCollapse.hide();
|
||||||
|
} else {
|
||||||
|
bsCollapse.show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update icon based on Bootstrap events (these should still work)
|
||||||
|
collapseEl.addEventListener('show.bs.collapse', () => {
|
||||||
|
console.log("Show event for:", collapseId);
|
||||||
|
toggleIcon.classList.remove('bi-chevron-right');
|
||||||
|
toggleIcon.classList.add('bi-chevron-down');
|
||||||
|
});
|
||||||
|
collapseEl.addEventListener('hide.bs.collapse', () => {
|
||||||
|
console.log("Hide event for:", collapseId);
|
||||||
|
toggleIcon.classList.remove('bi-chevron-down');
|
||||||
|
toggleIcon.classList.add('bi-chevron-right');
|
||||||
|
});
|
||||||
|
// Set initial icon state
|
||||||
|
if (collapseEl.classList.contains('show')) {
|
||||||
|
toggleIcon.classList.remove('bi-chevron-right');
|
||||||
|
toggleIcon.classList.add('bi-chevron-down');
|
||||||
|
} else {
|
||||||
|
toggleIcon.classList.remove('bi-chevron-down');
|
||||||
|
toggleIcon.classList.add('bi-chevron-right');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// --- END: Restore Collapse Handling ---
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Catch block for overall DOMContentLoaded function
|
||||||
|
} catch (e) {
|
||||||
|
console.error("JavaScript error in cp_view_processed_ig.html:", e);
|
||||||
|
const body = document.querySelector('body');
|
||||||
|
if (body) {
|
||||||
|
const errorDiv = document.createElement('div');
|
||||||
|
errorDiv.className = 'alert alert-danger m-3';
|
||||||
|
errorDiv.textContent = 'A critical error occurred while initializing the page view. Please check the console for details.';
|
||||||
|
// Avoid inserting before potentially null firstChild
|
||||||
|
if (body.firstChild) {
|
||||||
|
body.insertBefore(errorDiv, body.firstChild);
|
||||||
|
} else {
|
||||||
|
body.appendChild(errorDiv);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% endblock %}
|
@ -20,14 +20,14 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">Validation Form</div>
|
<div class="card-header">Validation Form</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="POST" id="validationForm">
|
<form id="validationForm">
|
||||||
{{ form.hidden_tag() }}
|
{{ form.hidden_tag() }}
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<small class="form-text text-muted">
|
<small class="form-text text-muted">
|
||||||
Select a package from the list below or enter a new one (e.g., <code>hl7.fhir.us.core</code>).
|
Select a package from the list below or enter a new one (e.g., <code>hl7.fhir.us.core</code>).
|
||||||
</small>
|
</small>
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<select class="form-select" id="packageSelect" onchange="updatePackageFields(this)">
|
<select class="form-select" id="packageSelect">
|
||||||
<option value="">-- Select a Package --</option>
|
<option value="">-- Select a Package --</option>
|
||||||
{% if packages %}
|
{% if packages %}
|
||||||
{% for pkg in packages %}
|
{% for pkg in packages %}
|
||||||
@ -69,58 +69,17 @@
|
|||||||
<div class="text-danger">{{ error }}</div>
|
<div class="text-danger">{{ error }}</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{{ form.submit(class="btn btn-primary") }}
|
<button type="button" class="btn btn-primary" id="validateButton">Validate</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if validation_report %}
|
<div class="card mt-4" id="validationResult" style="display: none;">
|
||||||
<div class="card mt-4">
|
<div class="card-header">Validation Report</div>
|
||||||
<div class="card-header">Validation Report</div>
|
<div class="card-body" id="validationContent">
|
||||||
<div class="card-body">
|
<!-- Results will be populated dynamically -->
|
||||||
<h5>Summary</h5>
|
|
||||||
<p><strong>Valid:</strong> {{ 'Yes' if validation_report.valid else 'No' }}</p>
|
|
||||||
{% if validation_report.errors %}
|
|
||||||
<h6>Errors</h6>
|
|
||||||
<ul class="text-danger">
|
|
||||||
{% for error in validation_report.errors %}
|
|
||||||
<li>{{ error }}</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
{% endif %}
|
|
||||||
{% if validation_report.warnings %}
|
|
||||||
<h6>Warnings</h6>
|
|
||||||
<ul class="text-warning">
|
|
||||||
{% for warning in validation_report.warnings %}
|
|
||||||
<li>{{ warning }}</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if validation_report.results %}
|
|
||||||
<h5>Detailed Results</h5>
|
|
||||||
{% for resource_id, result in validation_report.results.items() %}
|
|
||||||
<h6>{{ resource_id }}</h6>
|
|
||||||
<p><strong>Valid:</strong> {{ 'Yes' if result.valid else 'No' }}</p>
|
|
||||||
{% if result.errors %}
|
|
||||||
<ul class="text-danger">
|
|
||||||
{% for error in result.errors %}
|
|
||||||
<li>{{ error }}</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
{% endif %}
|
|
||||||
{% if result.warnings %}
|
|
||||||
<ul class="text-warning">
|
|
||||||
{% for warning in result.warnings %}
|
|
||||||
<li>{{ warning }}</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@ -128,6 +87,10 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
const packageSelect = document.getElementById('packageSelect');
|
const packageSelect = document.getElementById('packageSelect');
|
||||||
const packageNameInput = document.getElementById('{{ form.package_name.id }}');
|
const packageNameInput = document.getElementById('{{ form.package_name.id }}');
|
||||||
const versionInput = document.getElementById('{{ form.version.id }}');
|
const versionInput = document.getElementById('{{ form.version.id }}');
|
||||||
|
const validateButton = document.getElementById('validateButton');
|
||||||
|
const sampleInput = document.getElementById('{{ form.sample_input.id }}');
|
||||||
|
const validationResult = document.getElementById('validationResult');
|
||||||
|
const validationContent = document.getElementById('validationContent');
|
||||||
|
|
||||||
// Debug DOM elements
|
// Debug DOM elements
|
||||||
console.log('Package Select:', packageSelect ? 'Found' : 'Missing');
|
console.log('Package Select:', packageSelect ? 'Found' : 'Missing');
|
||||||
@ -174,7 +137,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
// Debug dropdown options
|
// Debug dropdown options
|
||||||
console.log('Dropdown options:', Array.from(packageSelect.options).map(opt => opt.value));
|
console.log('Dropdown options:', Array.from(packageSelect.options).map(opt => opt.value));
|
||||||
|
|
||||||
// Attach event listener explicitly
|
// Attach event listener for package selection
|
||||||
if (packageSelect) {
|
if (packageSelect) {
|
||||||
packageSelect.addEventListener('change', function() {
|
packageSelect.addEventListener('change', function() {
|
||||||
updatePackageFields(this);
|
updatePackageFields(this);
|
||||||
@ -182,19 +145,138 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Client-side JSON validation preview
|
// Client-side JSON validation preview
|
||||||
const sampleInput = document.getElementById('{{ form.sample_input.id }}');
|
|
||||||
if (sampleInput) {
|
if (sampleInput) {
|
||||||
sampleInput.addEventListener('input', function() {
|
sampleInput.addEventListener('input', function() {
|
||||||
try {
|
try {
|
||||||
JSON.parse(this.value);
|
if (this.value.trim()) {
|
||||||
this.classList.remove('is-invalid');
|
JSON.parse(this.value);
|
||||||
this.classList.add('is-valid');
|
this.classList.remove('is-invalid');
|
||||||
|
this.classList.add('is-valid');
|
||||||
|
} else {
|
||||||
|
this.classList.remove('is-valid', 'is-invalid');
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.classList.remove('is-valid');
|
this.classList.remove('is-valid');
|
||||||
this.classList.add('is-invalid');
|
this.classList.add('is-invalid');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate button handler
|
||||||
|
if (validateButton) {
|
||||||
|
validateButton.addEventListener('click', function() {
|
||||||
|
const packageName = packageNameInput.value;
|
||||||
|
const version = versionInput.value;
|
||||||
|
const sampleData = sampleInput.value.trim();
|
||||||
|
|
||||||
|
if (!packageName || !version) {
|
||||||
|
validationResult.style.display = 'block';
|
||||||
|
validationContent.innerHTML = '<div class="alert alert-danger">Please select a package and version.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sampleData) {
|
||||||
|
validationResult.style.display = 'block';
|
||||||
|
validationContent.innerHTML = '<div class="alert alert-danger">Please provide FHIR sample JSON.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
JSON.parse(sampleData);
|
||||||
|
} catch (e) {
|
||||||
|
validationResult.style.display = 'block';
|
||||||
|
validationContent.innerHTML = '<div class="alert alert-danger">Invalid JSON: ' + e.message + '</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
validationResult.style.display = 'block';
|
||||||
|
validationContent.innerHTML = '<div class="text-center py-3"><div class="spinner-border text-primary" role="status"><span class="visually-hidden">Validating...</span></div></div>';
|
||||||
|
|
||||||
|
console.log('Sending request to /api/validate-sample with:', {
|
||||||
|
package_name: packageName,
|
||||||
|
version: version,
|
||||||
|
sample_data_length: sampleData.length
|
||||||
|
});
|
||||||
|
|
||||||
|
fetch('/api/validate-sample', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': '{{ form.csrf_token._value() }}'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
package_name: packageName,
|
||||||
|
version: version,
|
||||||
|
sample_data: sampleData
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
console.log('Server response status:', response.status, response.statusText);
|
||||||
|
if (!response.ok) {
|
||||||
|
return response.text().then(text => {
|
||||||
|
console.error('Server response text:', text.substring(0, 200) + (text.length > 200 ? '...' : ''));
|
||||||
|
throw new Error(`HTTP error ${response.status}: ${text.substring(0, 100)}...`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
console.log('Validation response:', data);
|
||||||
|
validationContent.innerHTML = '';
|
||||||
|
let hasContent = false;
|
||||||
|
|
||||||
|
if (data.errors && data.errors.length > 0) {
|
||||||
|
hasContent = true;
|
||||||
|
const errorDiv = document.createElement('div');
|
||||||
|
errorDiv.className = 'alert alert-danger';
|
||||||
|
errorDiv.innerHTML = '<h5>Errors</h5><ul>' + data.errors.map(e => `<li>${e}</li>`).join('') + '</ul>';
|
||||||
|
validationContent.appendChild(errorDiv);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.warnings && data.warnings.length > 0) {
|
||||||
|
hasContent = true;
|
||||||
|
const warningDiv = document.createElement('div');
|
||||||
|
warningDiv.className = 'alert alert-warning';
|
||||||
|
warningDiv.innerHTML = '<h5>Warnings</h5><ul>' + data.warnings.map(w => `<li>${w}</li>`).join('') + '</ul>';
|
||||||
|
validationContent.appendChild(warningDiv);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.results) {
|
||||||
|
hasContent = true;
|
||||||
|
const resultsDiv = document.createElement('div');
|
||||||
|
resultsDiv.innerHTML = '<h5>Detailed Results</h5>';
|
||||||
|
for (const [resourceId, result] of Object.entries(data.results)) {
|
||||||
|
resultsDiv.innerHTML += `
|
||||||
|
<h6>${resourceId}</h6>
|
||||||
|
<p><strong>Valid:</strong> ${result.valid ? 'Yes' : 'No'}</p>
|
||||||
|
`;
|
||||||
|
if (result.errors && result.errors.length > 0) {
|
||||||
|
resultsDiv.innerHTML += '<ul class="text-danger">' +
|
||||||
|
result.errors.map(e => `<li>${e}</li>`).join('') + '</ul>';
|
||||||
|
}
|
||||||
|
if (result.warnings && result.warnings.length > 0) {
|
||||||
|
resultsDiv.innerHTML += '<ul class="text-warning">' +
|
||||||
|
result.warnings.map(w => `<li>${w}</li>`).join('') + '</ul>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
validationContent.appendChild(resultsDiv);
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaryDiv = document.createElement('div');
|
||||||
|
summaryDiv.className = 'alert alert-info';
|
||||||
|
summaryDiv.innerHTML = `<h5>Summary</h5><p>Valid: ${data.valid ? 'Yes' : 'No'}</p>`;
|
||||||
|
validationContent.appendChild(summaryDiv);
|
||||||
|
|
||||||
|
if (!hasContent && data.valid) {
|
||||||
|
validationContent.innerHTML = '<div class="alert alert-success">Validation passed with no errors or warnings.</div>';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Validation error:', error);
|
||||||
|
validationContent.innerHTML = '<div class="alert alert-danger">Error during validation: ' + error.message + '</div>';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
Loading…
x
Reference in New Issue
Block a user