From f087a6ef4971f6e3a99430a83fbb8cc3c77c6256 Mon Sep 17 00:00:00 2001 From: Ross Date: Mon, 22 Sep 2025 12:39:05 +0100 Subject: [PATCH] Add helper functions for question type inference and options management in question editor --- .../atlas/collection_case_questions.html | 468 ++++++++++-------- 1 file changed, 264 insertions(+), 204 deletions(-) diff --git a/atlas/templates/atlas/collection_case_questions.html b/atlas/templates/atlas/collection_case_questions.html index 1b1fbe97..d16de1da 100644 --- a/atlas/templates/atlas/collection_case_questions.html +++ b/atlas/templates/atlas/collection_case_questions.html @@ -172,6 +172,65 @@ const resetQuestionOptionsButton = document.getElementById('reset-question-options'); const questionsContainer = document.getElementById('questions-container'); + // Helper: get options array from a question regardless of schema shape + function getQuestionOptions(question) { + if (!question) return []; + if (Array.isArray(question.enum)) return question.enum.slice(); + if (question.items && Array.isArray(question.items.enum)) return question.items.enum.slice(); + return []; + } + + // Helper: infer a UI questionType from the schema if question.questionType is not present + function inferQuestionType(question) { + if (!question) return 'text'; + if (question.type === 'array') return 'multiselect'; + if (question.format === 'select2' && question.type === 'string' && Array.isArray(getQuestionOptions(question))) return 'dropdown-select'; + if (question.format === 'select' && question.type === 'string') return 'dropdown'; + if (question.format === 'radio' && question.type === 'string') return 'radio'; + if (question.format === 'textarea') return 'textarea'; + if (question.type === 'number') return 'number'; + if (question.type === 'range') return 'range'; + if (question.enum && Array.isArray(question.enum) && question.enum.length === 2) { + const opts = question.enum.map(String).map(s => s.toLowerCase()); + if ((opts.includes('yes') && opts.includes('no'))) return 'yesno'; + if ((opts.includes('true') && opts.includes('false'))) return 'truefalse'; + } + return 'text'; + } + + // Helper: apply an options array to a question object according to the selected UI type + function applyOptionsToQuestion(question, optionsArray, selectedType) { + optionsArray = (optionsArray || []).filter(o => o !== ''); + if (selectedType === 'multiselect') { + question.type = 'array'; + question.items = question.items || { type: 'string', enum: [] }; + question.items.enum = optionsArray.length ? optionsArray : (question.items.enum || []); + question.uniqueItems = true; + question.format = 'select2'; + delete question.enum; + } else { + // convert array->single if present + if (question.type === 'array' && question.items && Array.isArray(question.items.enum)) { + question.enum = question.items.enum.slice(); + delete question.items; + delete question.uniqueItems; + } + if (optionsArray.length) { + question.enum = optionsArray; + } + // ensure string type for single-select/other + if (question.enum && Array.isArray(question.enum)) { + question.type = 'string'; + } + // set formats for some known selected types + if (selectedType === 'dropdown-select') question.format = 'select2'; + else if (selectedType === 'dropdown') question.format = 'select'; + else if (selectedType === 'radio') question.format = 'radio'; + } + // always store the UI mapping + question.questionType = selectedType; + } + question_editor.updateProps({ onChange: (updatedContent, previousContent, context) => { // Call the original onChange handler if it exists @@ -332,7 +391,7 @@ description: description, questionType: selectedType, // Add questionType property }; - } else if (selectedType === 'multiselect') { + } else if (selectedType === 'multiselect') { newQuestion = { type: "array", items: { @@ -390,172 +449,100 @@ } - newQuestion.propertyOrder = current_question_number; // Set the property order + newQuestion.propertyOrder = current_question_number; // Set the property order // Add the new question to the properties - currentContent.properties[`question_${current_question_number}`] = newQuestion; + currentContent.properties[`question_${current_question_number}`] = newQuestion; // Update the editor with the new question - question_editor.update({ json: currentContent }); - toastr.success(`New question added: ${title}`); - renderQuestions(); // Re-render the questions list - } + question_editor.update({ json: currentContent }); + toastr.success(`New question added: ${title}`); + renderQuestions(); // Re-render the questions list + } - function renderQuestions() { - questionsContainer.innerHTML = ''; // Clear the container + function renderQuestions() { + questionsContainer.innerHTML = ''; // Clear the container + try { + let currentContent; try { - let currentContent; - try { - currentContent = JSON.parse(question_editor.get().text); - } catch { - currentContent = JSON.parse(JSON.stringify(question_editor.get().json)); - } - if (!currentContent || !currentContent.properties) { - console.warn('No properties found in the current content.'); - return; - } - const properties = currentContent.properties || {}; + currentContent = JSON.parse(question_editor.get().text); + } catch { + currentContent = JSON.parse(JSON.stringify(question_editor.get().json)); + } + if (!currentContent || !currentContent.properties) { + console.warn('No properties found in the current content.'); + return; + } + const properties = currentContent.properties || {}; // Sort questions by propertyOrder - const sortedKeys = Object.keys(properties).sort((a, b) => { - const orderA = properties[a].propertyOrder !== undefined ? properties[a].propertyOrder : 1000; - const orderB = properties[b].propertyOrder !== undefined ? properties[b].propertyOrder : 1000; - return orderA - orderB; - }); + const sortedKeys = Object.keys(properties).sort((a, b) => { + const orderA = properties[a].propertyOrder !== undefined ? properties[a].propertyOrder : 1000; + const orderB = properties[b].propertyOrder !== undefined ? properties[b].propertyOrder : 1000; + return orderA - orderB; + }); - sortedKeys.forEach((key, index) => { - const question = properties[key]; - const questionElement = document.createElement('div'); - questionElement.className = 'question-item'; - questionElement.style.marginBottom = '10px'; - questionElement.setAttribute('draggable', 'true'); // Make the element draggable - questionElement.setAttribute('data-question-key', key); - questionElement.setAttribute('data-index', index); + sortedKeys.forEach((key, index) => { + const question = properties[key]; + const questionElement = document.createElement('div'); + questionElement.className = 'question-item'; + questionElement.style.marginBottom = '10px'; + questionElement.setAttribute('draggable', 'true'); // Make the element draggable + questionElement.setAttribute('data-question-key', key); + questionElement.setAttribute('data-index', index); - questionElement.innerHTML = ` + questionElement.innerHTML = ` ${question.title || key} `; - questionsContainer.appendChild(questionElement); + questionsContainer.appendChild(questionElement); // Add event listener to delete the question - questionElement.querySelector('.delete-question-button').addEventListener('click', (e) => { - const questionKey = e.target.getAttribute('data-question-key'); - deleteQuestion(questionKey); - }); + questionElement.querySelector('.delete-question-button').addEventListener('click', (e) => { + const questionKey = e.target.getAttribute('data-question-key'); + deleteQuestion(questionKey); + }); // Add event listener to edit the question - questionElement.querySelector('.edit-question-button').addEventListener('click', (e) => { - const questionKey = e.target.getAttribute('data-question-key'); - editQuestion(questionKey); - }); + questionElement.querySelector('.edit-question-button').addEventListener('click', (e) => { + const questionKey = e.target.getAttribute('data-question-key'); + editQuestion(questionKey); + }); // Add drag-and-drop event listeners - questionElement.addEventListener('dragstart', handleDragStart); - questionElement.addEventListener('dragover', handleDragOver); - questionElement.addEventListener('drop', handleDrop); - questionElement.addEventListener('dragend', handleDragEnd); - }); - } catch (error) { - console.error('Error rendering questions:', error); - } + questionElement.addEventListener('dragstart', handleDragStart); + questionElement.addEventListener('dragover', handleDragOver); + questionElement.addEventListener('drop', handleDrop); + questionElement.addEventListener('dragend', handleDragEnd); + }); + } catch (error) { + console.error('Error rendering questions:', error); } + } // Drag-and-drop event handlers - let draggedElement = null; + let draggedElement = null; - function handleDragStart(e) { - draggedElement = this; - e.dataTransfer.effectAllowed = 'move'; - e.dataTransfer.setData('text/html', this.outerHTML); - this.classList.add('dragging'); - } + function handleDragStart(e) { + draggedElement = this; + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/html', this.outerHTML); + this.classList.add('dragging'); + } - function handleDragOver(e) { - e.preventDefault(); - e.dataTransfer.dropEffect = 'move'; - } + function handleDragOver(e) { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + } - function handleDrop(e) { - e.preventDefault(); - if (draggedElement !== this) { - const draggedIndex = parseInt(draggedElement.getAttribute('data-index')); - const targetIndex = parseInt(this.getAttribute('data-index')); + function handleDrop(e) { + e.preventDefault(); + if (draggedElement !== this) { + const draggedIndex = parseInt(draggedElement.getAttribute('data-index')); + const targetIndex = parseInt(this.getAttribute('data-index')); - try { - let currentContent; - try { - currentContent = JSON.parse(question_editor.get().text); - } catch { - currentContent = JSON.parse(JSON.stringify(question_editor.get().json)); - } - const properties = currentContent.properties || {}; - const keys = Object.keys(properties); - - // Sort keys by propertyOrder - const sortedKeys = keys.sort((a, b) => { - const orderA = properties[a].propertyOrder !== undefined ? properties[a].propertyOrder : Infinity; - const orderB = properties[b].propertyOrder !== undefined ? properties[b].propertyOrder : Infinity; - return orderA - orderB; - }); - - // Move the dragged question to the target position - const draggedKey = sortedKeys.splice(draggedIndex, 1)[0]; // Remove dragged key - sortedKeys.splice(targetIndex, 0, draggedKey); // Insert dragged key at target position - - // Reassign propertyOrder to ensure uniqueness and correct order - sortedKeys.forEach((key, index) => { - properties[key].propertyOrder = index + 1; - }); - - // Assign propertyOrder to any questions without it - keys.forEach((key) => { - if (properties[key].propertyOrder === undefined) { - properties[key].propertyOrder = sortedKeys.length + 1; - sortedKeys.push(key); - } - }); - - currentContent.properties = properties; - question_editor.update({ json: currentContent }); - toastr.success('Questions reordered successfully.'); - renderQuestions(); // Re-render the questions list - } catch (error) { - console.error('Error reordering questions:', error); - toastr.error('Failed to reorder questions.'); - } - } - } - - function handleDragEnd() { - this.classList.remove('dragging'); - } - - // Function to delete a question - function deleteQuestion(questionKey) { - try { - try { - currentContent = JSON.parse(question_editor.get().text); - } catch { - currentContent = JSON.parse(JSON.stringify(question_editor.get().json)); - } - if (currentContent.properties && currentContent.properties[questionKey]) { - delete currentContent.properties[questionKey]; // Remove the question - question_editor.update({ json: currentContent }); // Update the editor - toastr.success(`Question "${questionKey}" has been deleted.`); - renderQuestions(); // Re-render the questions list - } - } catch (error) { - console.error('Error deleting question:', error); - toastr.error('Failed to delete the question.'); - } - } - // Initial render of questions - renderQuestions(); - - function editQuestion(questionKey) { try { let currentContent; try { @@ -564,96 +551,169 @@ currentContent = JSON.parse(JSON.stringify(question_editor.get().json)); } const properties = currentContent.properties || {}; - const question = properties[questionKey]; + const keys = Object.keys(properties); - if (question) { + // Sort keys by propertyOrder + const sortedKeys = keys.sort((a, b) => { + const orderA = properties[a].propertyOrder !== undefined ? properties[a].propertyOrder : Infinity; + const orderB = properties[b].propertyOrder !== undefined ? properties[b].propertyOrder : Infinity; + return orderA - orderB; + }); + + // Move the dragged question to the target position + const draggedKey = sortedKeys.splice(draggedIndex, 1)[0]; // Remove dragged key + sortedKeys.splice(targetIndex, 0, draggedKey); // Insert dragged key at target position + + // Reassign propertyOrder to ensure uniqueness and correct order + sortedKeys.forEach((key, index) => { + properties[key].propertyOrder = index + 1; + }); + + // Assign propertyOrder to any questions without it + keys.forEach((key) => { + if (properties[key].propertyOrder === undefined) { + properties[key].propertyOrder = sortedKeys.length + 1; + sortedKeys.push(key); + } + }); + + currentContent.properties = properties; + question_editor.update({ json: currentContent }); + toastr.success('Questions reordered successfully.'); + renderQuestions(); // Re-render the questions list + } catch (error) { + console.error('Error reordering questions:', error); + toastr.error('Failed to reorder questions.'); + } + } + } + + function handleDragEnd() { + this.classList.remove('dragging'); + } + + // Function to delete a question + function deleteQuestion(questionKey) { + try { + try { + currentContent = JSON.parse(question_editor.get().text); + } catch { + currentContent = JSON.parse(JSON.stringify(question_editor.get().json)); + } + if (currentContent.properties && currentContent.properties[questionKey]) { + delete currentContent.properties[questionKey]; // Remove the question + question_editor.update({ json: currentContent }); // Update the editor + toastr.success(`Question "${questionKey}" has been deleted.`); + renderQuestions(); // Re-render the questions list + } + } catch (error) { + console.error('Error deleting question:', error); + toastr.error('Failed to delete the question.'); + } + } + // Initial render of questions + renderQuestions(); + + function editQuestion(questionKey) { + try { + let currentContent; + try { + currentContent = JSON.parse(question_editor.get().text); + } catch { + currentContent = JSON.parse(JSON.stringify(question_editor.get().json)); + } + const properties = currentContent.properties || {}; + const question = properties[questionKey]; + + if (question) { // Populate the form fields with the question's data - questionTitleInput.value = question.title || ''; - questionDescriptionInput.value = question.description || ''; - questionTypeSelect.value = question.questionType || 'text'; + questionTitleInput.value = question.title || ''; + questionDescriptionInput.value = question.description || ''; + questionTypeSelect.value = question.questionType || 'text'; // Clear existing options and populate them if applicable - while (questionOptionsContainer.firstChild) { - questionOptionsContainer.removeChild(questionOptionsContainer.firstChild); - } - if (question.enum) { - question.enum.forEach((option, index) => { - const optionInput = document.createElement('div'); - optionInput.className = 'option-input'; - optionInput.style.marginBottom = '5px'; - optionInput.innerHTML = ` + while (questionOptionsContainer.firstChild) { + questionOptionsContainer.removeChild(questionOptionsContainer.firstChild); + } + // Support both single-select (question.enum) and multiselect (question.items.enum) + const enumList = getQuestionOptions(question) || []; + if (enumList.length) { + enumList.forEach((option, index) => { + const optionInput = document.createElement('div'); + optionInput.className = 'option-input'; + optionInput.style.marginBottom = '5px'; + optionInput.innerHTML = ` `; - questionOptionsContainer.appendChild(optionInput); - - // Add event listener to remove the option - optionInput.querySelector('.remove-option-button').addEventListener('click', () => { - questionOptionsContainer.removeChild(optionInput); - }); + questionOptionsContainer.appendChild(optionInput); + // Add event listener to remove the option + optionInput.querySelector('.remove-option-button').addEventListener('click', () => { + questionOptionsContainer.removeChild(optionInput); }); - optionsConfigurator.style.display = 'block'; - } else { - optionsConfigurator.style.display = 'none'; - } + }); + optionsConfigurator.style.display = 'block'; + } else { + optionsConfigurator.style.display = 'none'; + } + // If questionType is not set, infer it from the schema + questionTypeSelect.value = question.questionType || inferQuestionType(question); // Update the "Add New Question" button to save changes - addNewQuestionButton.textContent = 'Save Changes'; - addNewQuestionButton.onclick = () => { - saveEditedQuestion(questionKey); - }; - document.getElementById('add-question-block').open = true; // Open the question block - document.getElementById('add-question-block').scrollIntoView({ behavior: 'smooth', block: "center" }); // Scroll to the question block + addNewQuestionButton.textContent = 'Save Changes'; + addNewQuestionButton.onclick = () => { + saveEditedQuestion(questionKey); + }; + document.getElementById('add-question-block').open = true; // Open the question block + document.getElementById('add-question-block').scrollIntoView({ behavior: 'smooth', block: "center" }); // Scroll to the question block - } - } catch (error) { - console.error('Error editing question:', error); - toastr.error('Failed to edit the question.'); } + } catch (error) { + console.error('Error editing question:', error); + toastr.error('Failed to edit the question.'); } + } - function saveEditedQuestion(questionKey) { + function saveEditedQuestion(questionKey) { + try { + let currentContent; try { - let currentContent; - try { - currentContent = JSON.parse(question_editor.get().text); - } catch { - currentContent = JSON.parse(JSON.stringify(question_editor.get().json)); - } - const properties = currentContent.properties || {}; - const question = properties[questionKey]; + currentContent = JSON.parse(question_editor.get().text); + } catch { + currentContent = JSON.parse(JSON.stringify(question_editor.get().json)); + } + const properties = currentContent.properties || {}; + const question = properties[questionKey]; - if (question) { + if (question) { // Update the question with the new values from the form - question.title = questionTitleInput.value || `Question ${questionKey}`; - question.description = questionDescriptionInput.value || ''; - question.format = questionTypeSelect.value; + question.title = questionTitleInput.value || `Question ${questionKey}`; + question.description = questionDescriptionInput.value || ''; + question.format = questionTypeSelect.value; // Update the options if applicable - if (question.enum) { - question.enum = Array.from(questionOptionsContainer.querySelectorAll('.option-field')) - .map((input) => input.value.trim()) - .filter((option) => option !== ''); // Exclude empty options - } + const newOptions = questionOptionsContainer ? Array.from(questionOptionsContainer.querySelectorAll('.option-field')).map((input) => input.value.trim()).filter((option) => option !== '') : []; + const selectedType = questionTypeSelect ? questionTypeSelect.value : null; + applyOptionsToQuestion(question, newOptions, selectedType); - // Update the editor with the modified question - currentContent.properties[questionKey] = question; - question_editor.update({ json: currentContent }); + // Update the editor with the modified question + currentContent.properties[questionKey] = question; + try { question_editor.update({ json: currentContent }); } catch (e) { console.warn('Failed to update editor after saveEditedQuestion', e); } - toastr.success(`Question "${question.title}" updated successfully.`); - renderQuestions(); // Re-render the questions list + toastr.success(`Question "${question.title}" updated successfully.`); + renderQuestions(); // Re-render the questions list // Reset the "Add New Question" button - addNewQuestionButton.textContent = 'Add New Question'; - addNewQuestionButton.onclick = addNewQuestion; - resetQuestion(); // Reset the question options - } - } catch (error) { - console.error('Error saving edited question:', error); - toastr.error('Failed to save the question.'); + addNewQuestionButton.textContent = 'Add New Question'; + addNewQuestionButton.onclick = addNewQuestion; + resetQuestion(); // Reset the question options } + } catch (error) { + console.error('Error saving edited question:', error); + toastr.error('Failed to save the question.'); } - }); + } + });