FHIRFLARE-IG-Toolkit/templates/validate_sample.html
Sudo-JHare 92fe759b0f V4.3 - Advanced Validation
V4.3  Advanced Validation

added full gamut of options and configuration for fully fleshed out validation
2025-08-29 00:11:24 +10:00

588 lines
28 KiB
HTML

{% 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">Validate FHIR Sample</h1>
<div class="col-lg-6 mx-auto">
<p class="lead mb-4">
Validate a FHIR resource or bundle against a selected Implementation Guide.
</p>
</div>
</div>
<div id="spinner-overlay" class="d-none position-fixed top-0 start-0 w-100 h-100 bg-dark bg-opacity-50 d-flex align-items-center justify-content-center" style="z-index: 1050;">
<div class="text-center text-white">
<div id="spinner-animation" style="width: 200px; height: 200px;"></div>
<p class="mt-3" id="spinner-message">Waiting, don't leave this page...</p>
</div>
</div>
<div class="container mt-4">
<div class="card">
<div class="card-header">Validation Form</div>
<div class="card-body">
<form id="validationForm" method="POST" action="/api/validate">
{{ form.csrf_token }}
<div class="mb-3">
<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>).
</small>
<div class="mt-2">
<select class="form-select" id="packageSelect">
<option value="">-- Select a Package --</option>
{% if packages %}
{% for pkg in packages %}
<option value="{{ pkg.name }}#{{ pkg.version }}">{{ pkg.name }}#{{ pkg.version }}</option>
{% endfor %}
{% else %}
<option value="" disabled>No packages available</option>
{% endif %}
</select>
</div>
<label for="{{ form.package_name.id }}" class="form-label">Package Name</label>
{{ form.package_name(class="form-control", id=form.package_name.id) }}
{% for error in form.package_name.errors %}
<div class="text-danger">{{ error }}</div>
{% endfor %}
</div>
<div class="mb-3">
<label for="{{ form.version.id }}" class="form-label">Package Version</label>
{{ form.version(class="form-control", id=form.version.id) }}
{% for error in form.version.errors %}
<div class="text-danger">{{ error }}</div>
{% endfor %}
</div>
<hr class="my-4">
<!-- New Fields from ValidationForm -->
<div class="mb-3">
<label for="fhir_resource" class="form-label">FHIR Resource (JSON or XML)</label>
{{ form.fhir_resource(class="form-control", rows=10, placeholder="Paste your FHIR JSON here...") }}
</div>
<div class="row g-3">
<div class="col-md-6">
<label for="fhir_version" class="form-label">FHIR Version</label>
{{ form.fhir_version(class="form-select") }}
</div>
</div>
<hr class="my-4">
<h5>Validation Flags</h5>
<div class="row g-3">
<div class="col-md-6">
<div class="form-check">
{{ form.do_native(class="form-check-input") }}
<label class="form-check-label" for="do_native">{{ form.do_native.label }}</label>
</div>
<div class="form-check">
{{ form.hint_about_must_support(class="form-check-input") }}
<label class="form-check-label" for="hint_about_must_support">{{ form.hint_about_must_support.label }}</label>
</div>
<div class="form-check">
{{ form.assume_valid_rest_references(class="form-check-input") }}
<label class="form-check-label" for="assume_valid_rest_references">{{ form.assume_valid_rest_references.label }}</label>
</div>
<div class="form-check">
{{ form.no_extensible_binding_warnings(class="form-check-input") }}
<label class="form-check-label" for="no_extensible_binding_warnings">{{ form.no_extensible_binding_warnings.label }}</label>
</div>
</div>
<div class="col-md-6">
<div class="form-check">
{{ form.show_times(class="form-check-input") }}
<label class="form-check-label" for="show_times">{{ form.show_times.label }}</label>
</div>
<div class="form-check">
{{ form.allow_example_urls(class="form-check-input") }}
<label class="form-check-label" for="allow_example_urls">{{ form.allow_example_urls.label }}</label>
</div>
<div class="form-check">
{{ form.check_ips_codes(class="form-check-input") }}
<label class="form-check-label" for="check_ips_codes">{{ form.check_ips_codes.label }}</label>
</div>
<div class="form-check">
{{ form.allow_any_extensions(class="form-check-input") }}
<label class="form-check-label" for="allow_any_extensions">{{ form.allow_any_extensions.label }}</label>
</div>
<div class="form-check">
{{ form.tx_routing(class="form-check-input") }}
<label class="form-check-label" for="tx_routing">{{ form.tx_routing.label }}</label>
</div>
</div>
</div>
<hr class="my-4">
<h5>Other Settings</h5>
<div class="row g-3">
<div class="col-md-6">
<label for="profiles" class="form-label">{{ form.profiles.label }}</label>
{{ form.profiles(class="form-control") }}
<small class="form-text text-muted">Comma-separated URLs, e.g., url1,url2</small>
</div>
<div class="col-md-6">
<label for="extensions" class="form-label">{{ form.extensions.label }}</label>
{{ form.extensions(class="form-control") }}
<small class="form-text text-muted">Comma-separated URLs, e.g., url1,url2</small>
</div>
<div class="col-md-6">
<label for="terminology_server" class="form-label">{{ form.terminology_server.label }}</label>
{{ form.terminology_server(class="form-control") }}
<small class="form-text text-muted">e.g., http://tx.fhir.org</small>
</div>
<div class="col-md-6">
<label for="snomed_ct_version" class="form-label">{{ form.snomed_ct_version.label }}</label>
{{ form.snomed_ct_version(class="form-select") }}
<small class="form-text text-muted">Select a SNOMED CT edition.</small>
</div>
</div>
<div class="mt-4 text-center">
<button type="submit" id="validateButton" class="btn btn-primary btn-lg">
Validate Resource
</button>
<button type="button" id="clearResultsButton" class="btn btn-secondary btn-lg d-none">
Clear Results
</button>
</div>
</form>
</div>
</div>
<div id="validationResult" class="card mt-4" style="display: none;">
<div class="card-header">Validation Results</div>
<div class="card-body" id="validationContent">
<!-- Results will be displayed here -->
</div>
</div>
</div>
<script src="{{ url_for('static', filename='js/lottie.min.js') }}"></script>
<script>
// --- Global Variables and Helper Functions ---
let currentLottieAnimation = null;
let pollInterval = null;
const spinnerOverlay = document.getElementById('spinner-overlay');
const spinnerMessage = document.getElementById('spinner-message');
const spinnerContainer = document.getElementById('spinner-animation');
const perPage = 10;
// Function to load the Lottie animation based on theme
function loadLottieAnimation(theme) {
if (currentLottieAnimation) {
currentLottieAnimation.destroy();
currentLottieAnimation = null;
}
const animationPath = (theme === 'dark')
? '{{ url_for('static', filename='animations/Validation abstract.json') }}'
: '{{ url_for('static', filename='animations/Validation abstract.json') }}';
try {
currentLottieAnimation = lottie.loadAnimation({
container: spinnerContainer,
renderer: 'svg',
loop: true,
autoplay: true,
path: animationPath
});
} catch(lottieError) {
console.error("Error loading Lottie animation:", lottieError);
}
}
// Function to show/hide the spinner with a custom message
function toggleSpinner(show, message = "Running validator...") {
if (show) {
spinnerMessage.textContent = message;
spinnerOverlay.classList.remove('d-none');
const currentTheme = document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
loadLottieAnimation(currentTheme);
} else {
spinnerOverlay.classList.add('d-none');
if (currentLottieAnimation) {
currentLottieAnimation.destroy();
currentLottieAnimation = null;
}
}
}
// --- Frontend logic starts here
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('validationForm');
const validateButton = document.getElementById('validateButton');
const clearResultsButton = document.getElementById('clearResultsButton');
const validationResult = document.getElementById('validationResult');
const validationContent = document.getElementById('validationContent');
const csrfToken = document.querySelector('input[name="csrf_token"]').value;
const packageSelect = document.getElementById('packageSelect');
const packageNameInput = document.getElementById('{{ form.package_name.id }}');
const versionInput = document.getElementById('{{ form.version.id }}');
const fhirResourceInput = document.getElementById('fhir_resource');
// Function to update package fields when a package is selected from the dropdown
function updatePackageFields(select) {
const value = select.value;
if (!packageNameInput || !versionInput) return;
if (value) {
const [name, version] = value.split('#');
if (name && version) {
packageNameInput.value = name;
versionInput.value = version;
} else {
packageNameInput.value = '';
versionInput.value = '';
}
} else {
packageNameInput.value = '';
versionInput.value = '';
}
}
// Sanitize text to prevent XSS
function sanitizeText(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Tidy up the display by paginating results
function renderResultList(container, items, title, isWarnings = false) {
container.innerHTML = '';
if (items.length === 0) {
return;
}
const wrapper = document.createElement('div');
const listId = `list-${title.toLowerCase()}`;
let initialItems = items.slice(0, perPage);
let remainingItems = items.slice(perPage);
const titleElement = document.createElement('h5');
titleElement.textContent = title;
wrapper.appendChild(titleElement);
const ul = document.createElement('ul');
ul.className = 'list-unstyled';
initialItems.forEach(item => {
const li = document.createElement('li');
li.innerHTML = `
<div class="alert alert-${isWarnings ? 'warning' : 'danger'}">
${sanitizeText(typeof item === 'string' ? item : item.message || item.issue)}
${isWarnings && (item.issue && item.issue.includes('Must Support')) ? ' <i class="bi bi-info-circle" title="This element is marked as Must Support in the profile."></i>' : ''}
</div>
`;
ul.appendChild(li);
});
wrapper.appendChild(ul);
if (remainingItems.length > 0) {
const toggleButton = document.createElement('a');
toggleButton.href = '#';
toggleButton.className = 'btn btn-sm btn-link text-decoration-none';
toggleButton.textContent = `Show More (${remainingItems.length} more)`;
const remainingUl = document.createElement('ul');
remainingUl.className = 'list-unstyled d-none';
remainingItems.forEach(item => {
const li = document.createElement('li');
li.innerHTML = `
<div class="alert alert-${isWarnings ? 'warning' : 'danger'}">
${sanitizeText(typeof item === 'string' ? item : item.message || item.issue)}
${isWarnings && (item.issue && item.issue.includes('Must Support')) ? ' <i class="bi bi-info-circle" title="This element is marked as Must Support in the profile."></i>' : ''}
</div>
`;
remainingUl.appendChild(li);
});
toggleButton.addEventListener('click', (e) => {
e.preventDefault();
if (remainingUl.classList.contains('d-none')) {
remainingUl.classList.remove('d-none');
toggleButton.textContent = 'Show Less';
} else {
remainingUl.classList.add('d-none');
toggleButton.textContent = `Show More (${remainingItems.length} more)`;
}
});
wrapper.appendChild(toggleButton);
wrapper.appendChild(remainingUl);
}
container.appendChild(wrapper);
}
function displayFinalReport(data) {
validationContent.innerHTML = '';
let hasContent = false;
clearResultsButton.classList.remove('d-none');
if (data.file_paths) {
// Use the paths returned from the server to create download links
const jsonFileName = data.file_paths.json.split('/').pop();
const htmlFileName = data.file_paths.html.split('/').pop();
const downloadDiv = document.createElement('div');
downloadDiv.className = 'alert alert-info d-flex justify-content-between align-items-center';
downloadDiv.innerHTML = `
<span>Download Raw Reports:</span>
<div>
<a href="/download/${jsonFileName}" class="btn btn-sm btn-secondary me-2" download>Download JSON</a>
<a href="/download/${htmlFileName}" class="btn btn-sm btn-secondary" download>Download HTML</a>
</div>
`;
validationContent.appendChild(downloadDiv);
hasContent = true;
}
if (data.errors && data.errors.length > 0) {
hasContent = true;
const errorContainer = document.createElement('div');
renderResultList(errorContainer, data.errors, 'Errors');
validationContent.appendChild(errorContainer);
}
if (data.warnings && data.warnings.length > 0) {
hasContent = true;
const warningContainer = document.createElement('div');
renderResultList(warningContainer, data.warnings, 'Warnings', true);
validationContent.appendChild(warningContainer);
}
if (data.results) {
hasContent = true;
const resultsDiv = document.createElement('div');
resultsDiv.innerHTML = '<h5>Detailed Results</h5>';
let index = 0;
for (const [resourceId, result] of Object.entries(data.results)) {
const collapseId = `result-collapse-${index++}`;
resultsDiv.innerHTML += `
<div class="card mb-2">
<div class="card-header" role="button" data-bs-toggle="collapse" data-bs-target="#${collapseId}" aria-expanded="false" aria-controls="${collapseId}">
<h6 class="mb-0">${sanitizeText(resourceId)}</h6>
<p class="mb-0"><strong>Valid:</strong> ${result.valid ? 'Yes' : 'No'}</p>
</div>
<div id="${collapseId}" class="collapse">
<div class="card-body">
${result.details && result.details.length > 0 ? `
<ul>
${result.details.map(d => `
<li class="${d.severity === 'error' ? 'text-danger' : d.severity === 'warning' ? 'text-warning' : 'text-info'}">
${sanitizeText(d.issue)}
${d.description && d.description !== d.issue ? `<br><small class="text-muted">${sanitizeText(d.description)}</small>` : ''}
</li>
`).join('')}
</ul>
` : '<p>No additional details.</p>'}
</div>
</div>
</div>
`;
}
validationContent.appendChild(resultsDiv);
}
if (data.summary) {
const summaryDiv = document.createElement('div');
summaryDiv.className = 'alert alert-info';
summaryDiv.innerHTML = `
<h5>Summary</h5>
<p>Valid: ${data.valid ? 'Yes' : 'No'}</p>
<p>Errors: ${data.summary.error_count || 0}, Warnings: ${data.summary.warning_count || 0}</p>
`;
validationContent.appendChild(summaryDiv);
hasContent = true;
}
if (!hasContent && data.valid) {
validationContent.innerHTML = '<div class="alert alert-success">Validation passed with no errors or warnings.</div>';
}
document.querySelectorAll('[data-bs-toggle="collapse"]').forEach(el => {
el.addEventListener('click', () => {
const target = document.querySelector(el.getAttribute('data-bs-target'));
target.classList.toggle('show');
});
});
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => {
new bootstrap.Tooltip(el);
});
}
function checkValidationStatus(taskId) {
fetch(`/api/check-validation-status/${taskId}`)
.then(response => {
if (response.status === 202) {
console.log('Validation still in progress...');
return null;
} else if (response.status === 200) {
clearInterval(pollInterval);
toggleSpinner(false);
return response.json();
} else {
clearInterval(pollInterval);
toggleSpinner(false);
return response.json().then(errorData => {
throw new Error(errorData.message);
});
}
})
.then(data => {
if (data && data.status === 'complete') {
validationResult.style.display = 'block';
displayFinalReport(data.result);
}
})
.catch(error => {
clearInterval(pollInterval);
toggleSpinner(false);
console.error('Validation failed during polling:', error);
validationResult.style.display = 'block';
validationContent.innerHTML = `<div class="alert alert-danger">An unexpected error occurred during validation: ${sanitizeText(error.message)}</div>`;
});
}
function runBlockingValidation(e) {
e.preventDefault(); // This is the crucial line to prevent default form submission
const packageName = document.getElementById('{{ form.package_name.id }}').value;
const version = document.getElementById('{{ form.version.id }}').value;
const sampleData = document.getElementById('fhir_resource').value.trim();
const csrfToken = document.querySelector('input[name="csrf_token"]').value;
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 or XML.</div>';
return;
}
toggleSpinner(true, "Running FHIR Validator (This may take up to 5 minutes on the first run).");
// Collect all form data including the new fields
const data = {
package_name: packageName,
version: version,
fhir_resource: sampleData,
fhir_version: document.getElementById('fhir_version').value,
do_native: document.getElementById('do_native').checked,
hint_about_must_support: document.getElementById('hint_about_must_support').checked,
assume_valid_rest_references: document.getElementById('assume_valid_rest_references').checked,
no_extensible_binding_warnings: document.getElementById('no_extensible_binding_warnings').checked,
show_times: document.getElementById('show_times').checked,
allow_example_urls: document.getElementById('allow_example_urls').checked,
check_ips_codes: document.getElementById('check_ips_codes').checked,
allow_any_extensions: document.getElementById('allow_any_extensions').checked,
tx_routing: document.getElementById('tx_routing').checked,
snomed_ct_version: document.getElementById('snomed_ct_version').value,
profiles: document.getElementById('profiles').value,
extensions: document.getElementById('extensions').value,
terminology_server: document.getElementById('terminology_server').value
};
fetch('/api/validate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify(data)
})
.then(response => {
if (response.status === 202) {
return response.json();
} else {
toggleSpinner(false);
return response.json().then(errorData => {
throw new Error(errorData.message);
});
}
})
.then(data => {
if (data.status === 'accepted' && data.task_id) {
pollInterval = setInterval(() => checkValidationStatus(data.task_id), 3000);
} else {
toggleSpinner(false);
validationResult.style.display = 'block';
validationContent.innerHTML = `<div class="alert alert-danger">Server response format unexpected.</div>`;
}
})
.catch(error => {
clearInterval(pollInterval);
toggleSpinner(false);
console.error('Validation failed:', error);
validationResult.style.display = 'block';
validationContent.innerHTML = `<div class="alert alert-danger">An unexpected error occurred during validation: ${sanitizeText(error.message)}</div>`;
});
};
clearResultsButton.addEventListener('click', function() {
fetch('/api/clear-validation-results', {
method: 'POST',
headers: {
'X-CSRFToken': csrfToken
}
})
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
validationResult.style.display = 'none';
clearResultsButton.classList.add('d-none');
} else {
console.error('Failed to clear results:', data.message);
}
})
.catch(error => {
console.error('Network error while clearing results:', error);
});
});
if (packageSelect) {
packageSelect.addEventListener('change', function() {
updatePackageFields(this);
});
}
if (validateButton) {
validateButton.addEventListener('click', function(e) {
runBlockingValidation(e);
});
}
if (packageNameInput.value && versionInput.value) {
const currentValue = `${packageNameInput.value}#${versionInput.value}`;
const optionExists = Array.from(packageSelect.options).some(opt => opt.value === currentValue);
if (optionExists) {
packageSelect.value = currentValue;
}
}
if (fhirResourceInput) {
fhirResourceInput.addEventListener('input', function() {
try {
if (this.value.trim()) {
JSON.parse(this.value);
this.classList.remove('is-invalid');
this.classList.add('is-valid');
} else {
this.classList.remove('is-valid', 'is-invalid');
}
} catch (e) {
this.classList.remove('is-valid');
this.classList.add('is-invalid');
}
});
}
});
</script>
{% endblock %}