Add helper functions for question type inference and options management in question editor

This commit is contained in:
Ross
2025-09-22 12:39:05 +01:00
parent ac562ed1e1
commit f087a6ef49
@@ -172,6 +172,65 @@
const resetQuestionOptionsButton = document.getElementById('reset-question-options'); const resetQuestionOptionsButton = document.getElementById('reset-question-options');
const questionsContainer = document.getElementById('questions-container'); 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({ question_editor.updateProps({
onChange: (updatedContent, previousContent, context) => { onChange: (updatedContent, previousContent, context) => {
// Call the original onChange handler if it exists // Call the original onChange handler if it exists
@@ -332,7 +391,7 @@
description: description, description: description,
questionType: selectedType, // Add questionType property questionType: selectedType, // Add questionType property
}; };
} else if (selectedType === 'multiselect') { } else if (selectedType === 'multiselect') {
newQuestion = { newQuestion = {
type: "array", type: "array",
items: { 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 // 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 // Update the editor with the new question
question_editor.update({ json: currentContent }); question_editor.update({ json: currentContent });
toastr.success(`New question added: ${title}`); toastr.success(`New question added: ${title}`);
renderQuestions(); // Re-render the questions list renderQuestions(); // Re-render the questions list
} }
function renderQuestions() { function renderQuestions() {
questionsContainer.innerHTML = ''; // Clear the container questionsContainer.innerHTML = ''; // Clear the container
try {
let currentContent;
try { try {
let currentContent; currentContent = JSON.parse(question_editor.get().text);
try { } catch {
currentContent = JSON.parse(question_editor.get().text); currentContent = JSON.parse(JSON.stringify(question_editor.get().json));
} catch { }
currentContent = JSON.parse(JSON.stringify(question_editor.get().json)); if (!currentContent || !currentContent.properties) {
} console.warn('No properties found in the current content.');
if (!currentContent || !currentContent.properties) { return;
console.warn('No properties found in the current content.'); }
return; const properties = currentContent.properties || {};
}
const properties = currentContent.properties || {};
// Sort questions by propertyOrder // Sort questions by propertyOrder
const sortedKeys = Object.keys(properties).sort((a, b) => { const sortedKeys = Object.keys(properties).sort((a, b) => {
const orderA = properties[a].propertyOrder !== undefined ? properties[a].propertyOrder : 1000; const orderA = properties[a].propertyOrder !== undefined ? properties[a].propertyOrder : 1000;
const orderB = properties[b].propertyOrder !== undefined ? properties[b].propertyOrder : 1000; const orderB = properties[b].propertyOrder !== undefined ? properties[b].propertyOrder : 1000;
return orderA - orderB; return orderA - orderB;
}); });
sortedKeys.forEach((key, index) => { sortedKeys.forEach((key, index) => {
const question = properties[key]; const question = properties[key];
const questionElement = document.createElement('div'); const questionElement = document.createElement('div');
questionElement.className = 'question-item'; questionElement.className = 'question-item';
questionElement.style.marginBottom = '10px'; questionElement.style.marginBottom = '10px';
questionElement.setAttribute('draggable', 'true'); // Make the element draggable questionElement.setAttribute('draggable', 'true'); // Make the element draggable
questionElement.setAttribute('data-question-key', key); questionElement.setAttribute('data-question-key', key);
questionElement.setAttribute('data-index', index); questionElement.setAttribute('data-index', index);
questionElement.innerHTML = ` questionElement.innerHTML = `
<strong>${question.title || key}</strong> <strong>${question.title || key}</strong>
<button type="button" class="btn btn-warning btn-sm edit-question-button" data-question-key="${key}" style="margin-left: 10px;">Edit</button> <button type="button" class="btn btn-warning btn-sm edit-question-button" data-question-key="${key}" style="margin-left: 10px;">Edit</button>
<button type="button" class="btn btn-danger btn-sm delete-question-button" data-question-key="${key}" style="margin-left: 10px;">Delete</button> <button type="button" class="btn btn-danger btn-sm delete-question-button" data-question-key="${key}" style="margin-left: 10px;">Delete</button>
`; `;
questionsContainer.appendChild(questionElement); questionsContainer.appendChild(questionElement);
// Add event listener to delete the question // Add event listener to delete the question
questionElement.querySelector('.delete-question-button').addEventListener('click', (e) => { questionElement.querySelector('.delete-question-button').addEventListener('click', (e) => {
const questionKey = e.target.getAttribute('data-question-key'); const questionKey = e.target.getAttribute('data-question-key');
deleteQuestion(questionKey); deleteQuestion(questionKey);
}); });
// Add event listener to edit the question // Add event listener to edit the question
questionElement.querySelector('.edit-question-button').addEventListener('click', (e) => { questionElement.querySelector('.edit-question-button').addEventListener('click', (e) => {
const questionKey = e.target.getAttribute('data-question-key'); const questionKey = e.target.getAttribute('data-question-key');
editQuestion(questionKey); editQuestion(questionKey);
}); });
// Add drag-and-drop event listeners // Add drag-and-drop event listeners
questionElement.addEventListener('dragstart', handleDragStart); questionElement.addEventListener('dragstart', handleDragStart);
questionElement.addEventListener('dragover', handleDragOver); questionElement.addEventListener('dragover', handleDragOver);
questionElement.addEventListener('drop', handleDrop); questionElement.addEventListener('drop', handleDrop);
questionElement.addEventListener('dragend', handleDragEnd); questionElement.addEventListener('dragend', handleDragEnd);
}); });
} catch (error) { } catch (error) {
console.error('Error rendering questions:', error); console.error('Error rendering questions:', error);
}
} }
}
// Drag-and-drop event handlers // Drag-and-drop event handlers
let draggedElement = null; let draggedElement = null;
function handleDragStart(e) { function handleDragStart(e) {
draggedElement = this; draggedElement = this;
e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/html', this.outerHTML); e.dataTransfer.setData('text/html', this.outerHTML);
this.classList.add('dragging'); this.classList.add('dragging');
} }
function handleDragOver(e) { function handleDragOver(e) {
e.preventDefault(); e.preventDefault();
e.dataTransfer.dropEffect = 'move'; e.dataTransfer.dropEffect = 'move';
} }
function handleDrop(e) { function handleDrop(e) {
e.preventDefault(); e.preventDefault();
if (draggedElement !== this) { if (draggedElement !== this) {
const draggedIndex = parseInt(draggedElement.getAttribute('data-index')); const draggedIndex = parseInt(draggedElement.getAttribute('data-index'));
const targetIndex = parseInt(this.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 { try {
let currentContent; let currentContent;
try { try {
@@ -564,96 +551,169 @@
currentContent = JSON.parse(JSON.stringify(question_editor.get().json)); currentContent = JSON.parse(JSON.stringify(question_editor.get().json));
} }
const properties = currentContent.properties || {}; 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 // Populate the form fields with the question's data
questionTitleInput.value = question.title || ''; questionTitleInput.value = question.title || '';
questionDescriptionInput.value = question.description || ''; questionDescriptionInput.value = question.description || '';
questionTypeSelect.value = question.questionType || 'text'; questionTypeSelect.value = question.questionType || 'text';
// Clear existing options and populate them if applicable // Clear existing options and populate them if applicable
while (questionOptionsContainer.firstChild) { while (questionOptionsContainer.firstChild) {
questionOptionsContainer.removeChild(questionOptionsContainer.firstChild); questionOptionsContainer.removeChild(questionOptionsContainer.firstChild);
} }
if (question.enum) { // Support both single-select (question.enum) and multiselect (question.items.enum)
question.enum.forEach((option, index) => { const enumList = getQuestionOptions(question) || [];
const optionInput = document.createElement('div'); if (enumList.length) {
optionInput.className = 'option-input'; enumList.forEach((option, index) => {
optionInput.style.marginBottom = '5px'; const optionInput = document.createElement('div');
optionInput.innerHTML = ` optionInput.className = 'option-input';
optionInput.style.marginBottom = '5px';
optionInput.innerHTML = `
<input type="text" class="form-control option-field" value="${option}" placeholder="Option ${index + 1}" style="display: inline-block; width: 90%;"> <input type="text" class="form-control option-field" value="${option}" placeholder="Option ${index + 1}" style="display: inline-block; width: 90%;">
<button type="button" class="btn btn-danger btn-sm remove-option-button" style="display: inline-block; width: 8%;">&times;</button> <button type="button" class="btn btn-danger btn-sm remove-option-button" style="display: inline-block; width: 8%;">&times;</button>
`; `;
questionOptionsContainer.appendChild(optionInput); questionOptionsContainer.appendChild(optionInput);
// Add event listener to remove the option
// Add event listener to remove the option optionInput.querySelector('.remove-option-button').addEventListener('click', () => {
optionInput.querySelector('.remove-option-button').addEventListener('click', () => { questionOptionsContainer.removeChild(optionInput);
questionOptionsContainer.removeChild(optionInput);
});
}); });
optionsConfigurator.style.display = 'block'; });
} else { optionsConfigurator.style.display = 'block';
optionsConfigurator.style.display = 'none'; } 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 // Update the "Add New Question" button to save changes
addNewQuestionButton.textContent = 'Save Changes'; addNewQuestionButton.textContent = 'Save Changes';
addNewQuestionButton.onclick = () => { addNewQuestionButton.onclick = () => {
saveEditedQuestion(questionKey); saveEditedQuestion(questionKey);
}; };
document.getElementById('add-question-block').open = true; // Open the question block 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 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 { try {
let currentContent; currentContent = JSON.parse(question_editor.get().text);
try { } catch {
currentContent = JSON.parse(question_editor.get().text); currentContent = JSON.parse(JSON.stringify(question_editor.get().json));
} catch { }
currentContent = JSON.parse(JSON.stringify(question_editor.get().json)); const properties = currentContent.properties || {};
} const question = properties[questionKey];
const properties = currentContent.properties || {};
const question = properties[questionKey];
if (question) { if (question) {
// Update the question with the new values from the form // Update the question with the new values from the form
question.title = questionTitleInput.value || `Question ${questionKey}`; question.title = questionTitleInput.value || `Question ${questionKey}`;
question.description = questionDescriptionInput.value || ''; question.description = questionDescriptionInput.value || '';
question.format = questionTypeSelect.value; question.format = questionTypeSelect.value;
// Update the options if applicable // Update the options if applicable
if (question.enum) { const newOptions = questionOptionsContainer ? Array.from(questionOptionsContainer.querySelectorAll('.option-field')).map((input) => input.value.trim()).filter((option) => option !== '') : [];
question.enum = Array.from(questionOptionsContainer.querySelectorAll('.option-field')) const selectedType = questionTypeSelect ? questionTypeSelect.value : null;
.map((input) => input.value.trim()) applyOptionsToQuestion(question, newOptions, selectedType);
.filter((option) => option !== ''); // Exclude empty options
}
// Update the editor with the modified question // Update the editor with the modified question
currentContent.properties[questionKey] = question; currentContent.properties[questionKey] = question;
question_editor.update({ json: currentContent }); try { question_editor.update({ json: currentContent }); } catch (e) { console.warn('Failed to update editor after saveEditedQuestion', e); }
toastr.success(`Question "${question.title}" updated successfully.`); toastr.success(`Question "${question.title}" updated successfully.`);
renderQuestions(); // Re-render the questions list renderQuestions(); // Re-render the questions list
// Reset the "Add New Question" button // Reset the "Add New Question" button
addNewQuestionButton.textContent = 'Add New Question'; addNewQuestionButton.textContent = 'Add New Question';
addNewQuestionButton.onclick = addNewQuestion; addNewQuestionButton.onclick = addNewQuestion;
resetQuestion(); // Reset the question options resetQuestion(); // Reset the question options
}
} catch (error) {
console.error('Error saving edited question:', error);
toastr.error('Failed to save the question.');
} }
} catch (error) {
console.error('Error saving edited question:', error);
toastr.error('Failed to save the question.');
} }
}); }
});