mirror of
https://github.com/Sudo-JHare/FHIRFLARE-IG-Toolkit.git
synced 2025-09-17 18:35:02 +00:00
Remove Hapi validaotr replace with Hl7 Validator restructure validation report provide Json and Html validation reports remove hardcoded local references and couple to ENV allowing custom env Fhir Server URl to be set for local ""instance:
497 lines
22 KiB
HTML
497 lines
22 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">
|
|
{{ form.hidden_tag() }}
|
|
<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>
|
|
<div class="mb-3">
|
|
<label for="{{ form.include_dependencies.id }}" class="form-label">Include Dependencies</label>
|
|
<div class="form-check">
|
|
{{ form.include_dependencies(class="form-check-input") }}
|
|
{{ form.include_dependencies.label(class="form-check-label") }}
|
|
</div>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label for="{{ form.mode.id }}" class="form-label">Validation Mode</label>
|
|
{{ form.mode(class="form-select") }}
|
|
</div>
|
|
<div class="mb-3">
|
|
<label for="{{ form.sample_input.id }}" class="form-label">Sample FHIR Resource/Bundle (JSON)</label>
|
|
{{ form.sample_input(class="form-control", rows=10, placeholder="Paste your FHIR JSON here...") }}
|
|
{% for error in form.sample_input.errors %}
|
|
<div class="text-danger">{{ error }}</div>
|
|
{% endfor %}
|
|
</div>
|
|
<div class="d-flex justify-content-between">
|
|
<button type="button" class="btn btn-primary" id="validateButton">Validate</button>
|
|
<button type="button" class="btn btn-secondary d-none" id="clearResultsButton">Clear Results</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card mt-4" id="validationResult" style="display: none;">
|
|
<div class="card-header">Validation Report</div>
|
|
<div class="card-body" id="validationContent">
|
|
</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');
|
|
|
|
// 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 packageSelect = document.getElementById('packageSelect');
|
|
const packageNameInput = document.getElementById('{{ form.package_name.id }}');
|
|
const versionInput = document.getElementById('{{ form.version.id }}');
|
|
const validateButton = document.getElementById('validateButton');
|
|
const clearResultsButton = document.getElementById('clearResultsButton');
|
|
const sampleInput = document.getElementById('{{ form.sample_input.id }}');
|
|
const validationResult = document.getElementById('validationResult');
|
|
const validationContent = document.getElementById('validationContent');
|
|
const csrfToken = document.querySelector('input[name="csrf_token"]').value;
|
|
const perPage = 10;
|
|
|
|
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 = '';
|
|
}
|
|
}
|
|
|
|
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 (packageSelect) {
|
|
packageSelect.addEventListener('change', function() {
|
|
updatePackageFields(this);
|
|
});
|
|
}
|
|
|
|
if (sampleInput) {
|
|
sampleInput.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');
|
|
}
|
|
});
|
|
}
|
|
|
|
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.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.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) {
|
|
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() {
|
|
const packageName = packageNameInput.value;
|
|
const version = versionInput.value;
|
|
const sampleData = sampleInput.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.</div>';
|
|
return;
|
|
}
|
|
|
|
try {
|
|
JSON.parse(sampleData);
|
|
} catch (e) {
|
|
validationResult.style.display = 'block';
|
|
validationContent.innerHTML = '<div class="alert alert-danger">Invalid JSON: ' + sanitizeText(e.message) + '</div>';
|
|
return;
|
|
}
|
|
|
|
toggleSpinner(true, "Running FHIR Validator (This may take up to 5 minutes on the first run).");
|
|
|
|
fetch('/api/validate-sample', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRFToken': csrfToken
|
|
},
|
|
body: JSON.stringify({
|
|
package_name: packageName,
|
|
version: version,
|
|
sample_data: sampleData
|
|
})
|
|
})
|
|
.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: ${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 (validateButton) {
|
|
validateButton.addEventListener('click', function() {
|
|
runBlockingValidation();
|
|
});
|
|
}
|
|
});
|
|
</script>
|
|
{% endblock %}
|