Using custom HTML/JS for input block

Hi everyone,

I'm working on integrating a custom HTML/JavaScript template into Prodigy, but I'm running into an issue where the text fields and dropdowns in my code snippet are not being displayed properly.

Here's the relevant part of my code:

blocks = [
        {
            "view_id": "html",
            "html_template": """
                <style>
        .container {
            display: flex;
            gap: 10px;
            align-items: center;
            margin-bottom: 10px;
        }
        .container input,
        .container select {
            padding: 5px;
            font-size: 14px;
        }
    </style>
    <div id='container' class='container'></div>
    <button id='add-more-button'>Add More Information</button>
    <script>
        const container = document.getElementById('container');

        const dictionaryOptions = ['Error 1', 'Error 2', 'Error 3'];
        const severityOptions = ['Critical', 'Major', 'Minor'];

        function createRow() {
            const row = document.createElement('div');
            row.className = 'container';

            const textField1 = document.createElement('input');
            textField1.type = 'text';
            textField1.placeholder = 'Term';

            const textField2 = document.createElement('input');
            textField2.type = 'text';
            textField2.placeholder = 'Justification';

            const dropdown1 = document.createElement('select');
            dictionaryOptions.forEach(opt => {
                const option = document.createElement('option');
                option.value = opt;
                option.textContent = opt;
                dropdown1.appendChild(option);
            });

            const dropdown2 = document.createElement('select');
            severityOptions.forEach(opt => {
                const option = document.createElement('option');
                option.value = opt;
                option.textContent = opt;
                dropdown2.appendChild(option);
            });

            row.appendChild(textField1);
            row.appendChild(textField2);
            row.appendChild(dropdown1);
            row.appendChild(dropdown2);

            return row;
        }

        container.appendChild(createRow());

        document.getElementById('add-more-button').addEventListener('click', () => {
            const newRow = createRow();
            container.parentNode.insertBefore(newRow, document.getElementById('add-more-button'));
        });
    </script>
            """,
        },
    ]

When I try to use this template in Prodigy, the text fields and dropdowns are not being displayed as expected. As you can see in the following image, the placeholders seem to be displayed, but the elements are not showing.

This is the expected result, using an online HTML interpreter. Originally it renders the first row (containing 2 text fields and 2 dropdowns) and the 'Add More Information' button that generates another row:

My question are:

  • Is this feature feasible in the current version of Prodigy?
  • If so, how can I achieve to render this custom code?
  • Also, how can I save the annotated information when executing prodigy db-out? As the number of rows would be undetermined because they are generated by the user.

Thanks in advance, any guidance or suggestions would be greatly appreciated! I'd love to hear your thoughts on this :slight_smile:

Welcome to the forum @miguelclaramunt! :waving_hand:

What you're describing should definitely be possible with custom html, css and js.

I think the main problem in your current implementation is the container structure. By using container.parentNode.insertBefore you are inserting the new row as a sibling to the container, not inside it.

In order to save the data introduced via this form, you will have to call prodigy.update function in the javascript handler of the form.
Since it requires a couple of custom javascript functions (for adding rows, saving the data and resetting the rows) I split the solution into html, css and javascript files to make it more readable and to be able to pass javascript code to Prodigy.

Starting with the html:
Since we are not going to use any variables, we don't really need a template. Instead, we can just pass the following html string to html view:

<div id="form-container"></div>
<button id="add-row-btn">Add More Information</button>

Here we define just the form container and the button.
The rows will be filled by a javascript script function just like in your implementation.

The javascript will have to take care of:

  1. Defining a function to add new rows - exactly like in your implementation
function createRow() {
  const row = document.createElement('div');
  row.className = 'row-container';
 
  const termInput = document.createElement('input');
  termInput.placeholder = 'Term';
  termInput.className = 'term-input';
  
  const justificationInput = document.createElement('input');
  justificationInput.placeholder = 'Justification';
  justificationInput.className = 'justification-input';
  
  const errorSelect = document.createElement('select');
  errorSelect.className = 'error-select';
  const errorOptions = ['Error 1', 'Error 2', 'Error 3'];
  errorOptions.forEach(opt => {
    const option = document.createElement('option');
    option.textContent = opt;
    errorSelect.appendChild(option);
  });
  
  const severitySelect = document.createElement('select');
  severitySelect.className = 'severity-select';
  const severityOptions = ['Critical', 'Major', 'Minor'];
  severityOptions.forEach(opt => {
    const option = document.createElement('option');
    option.textContent = opt;
    severitySelect.appendChild(option);
  });
  
  // Append all elements to the row
  row.appendChild(termInput);
  row.appendChild(justificationInput);
  row.appendChild(errorSelect);
  row.appendChild(severitySelect);
  
  return row;
}
  1. Adding the first 2 rows:
const formContainer = document.getElementById('form-container');
  formContainer.appendChild(createRow());
  formContainer.appendChild(createRow());

Contrary to your implementation, we are appending as children to the form container.

  1. Add a listener to the button with createRow callback:
const addButton = document.getElementById('add-row-btn');
  addButton.addEventListener('click', function() {
    formContainer.appendChild(createRow());
  });
  1. Add listeners to the form fields so that the internal form structure is updated every time the user modifies the form. This done by calling prodigy.update function that updates the Prodigy task by perfoming a shallow dictionary merge:
 formContainer.addEventListener('input', function(event) {
    // Check if the event target is an input or select element
    if (event.target.tagName === 'INPUT' || event.target.tagName === 'SELECT') {
      // Update Prodigy task whenever any input or select value changes
      updateProdigyTask();
    }
  });
  
  // Also listen for change events (for selects)
  formContainer.addEventListener('change', function(event) {
    if (event.target.tagName === 'SELECT') {
      updateProdigyTask();
    }
  });
  1. Define the updateProdigyTask function:
 function updateProdigyTask() {
    // Collect all row containers
    const rowContainers = document.querySelectorAll('.row-container');
    
    // Create an array to store the form data
    const formData = [];
    
    // Iterate through each row and extract values
    rowContainers.forEach((row) => {
      // Use more specific selectors to get the correct inputs
      const termInput = row.querySelector('input[placeholder="Term"]');
      const justificationInput = row.querySelector('input[placeholder="Justification"]');
      const errorSelect = row.querySelector('select:first-of-type');
      const severitySelect = row.querySelector('select:last-of-type');
      
   
      // Create an object for this row
      const rowData = {
      term: termInput.value.trim(),
      justification: justificationInput.value.trim(),
      errorType: errorSelect.value,
      severity: severitySelect.value
      };
        
      // Only add non-empty rows (at least term or justification must be filled)
      if (rowData.term || rowData.justification) {
         formData.push(rowData);
       }
    });
    
   // Update the Prodigy task with the collected form data
   // this will add a new key `form_field` to Prodigy task saved in the DB
   prodigy.update({ "form_field": formData });
   console.log('Updated Prodigy task with form data:', formData);
  }
  1. We'll also need a form reset function to make sure that the new task gets an empty form. For that we'll listen to prodigyanswer event and call the function whenever the user submits the task:
document.addEventListener('prodigyanswer', () => {
    // Reset the form after saving
    resetForm();
  });
function resetForm() {
  const formContainer = document.getElementById('form-container');
  
  // Clear all existing rows
  formContainer.innerHTML = '';
  
  // Add back two empty rows (default state)
  formContainer.appendChild(createRow());
  formContainer.appendChild(createRow());
  
  console.log('Form has been reset');
}

Finally the suggested styling:

.row-container {
  display: flex;
  margin-bottom: 10px;
  align-items: center;
}

input, select {
  padding: 6px;
  margin-right: 10px;
  border: 1px solid #ccc;
}

input {
  width: 180px;
}

select {
  width: 90px;
}

#add-row-btn {
  background-color: #5a3cc0;
  color: white;
  border: none;
  padding: 10px 20px;
  text-transform: uppercase;
  font-weight: bold;
  cursor: pointer;
}

You can plug in the html, css, and javascript in the recipe like so:

from pathlib import Path

import prodigy
from prodigy.components.stream import get_stream
from prodigy.core import Arg


@prodigy.recipe(
    "form",
    dataset=Arg(help="Dataset to save answers to"),
    source=Arg(help="Input dataset"),
)
def form(dataset: str, source: Path):
    stream = get_stream(source)
    custom_js = Path("custom.js").read_text()
    html = Path("form.html").read_text()
    custom_css = Path("custom.css").read_text()
    blocks = [
        {"view_id": "text"},
        {"view_id": "html", "html": html},
    ]

    return {
        "dataset": dataset,
        "stream": stream,
        "view_id": "blocks",
        "config": {
            "blocks": blocks,
            "global_css": custom_css,
            "javascript": custom_js,
        },
    }

This should result in the following UI:

And the resulting structure of a saved task in the database should be:

{
  "text": "Uber’s Lesson: Silicon Valley’s Start-Up Machine Needs Fixing",
  "meta": {
    "source": "The New York Times"
  },
  "html": " ",
  "_input_hash": -1450484177,
  "_task_hash": 178937675,
  "_view_id": "blocks",
  "form_field": [
    {
      "term": "a",
      "justification": "foo bar",
      "errorType": "Error 1",
      "severity": "Critical"
    },
    {
      "term": "b",
      "justification": "baz",
      "errorType": "Error 2",
      "severity": "Minor"
    },
    {
      "term": "c",
      "justification": "qux",
      "errorType": "Error 3",
      "severity": "Major"
    }
  ],
  "answer": "accept",
  "_timestamp": 1744206583,
  "_annotator_id": "2025-04-09_15-48-35",
  "_session_id": "2025-04-09_15-48-35"
}

As you can see there's form_field key where each row is saved as dictionary.

Please note that this does not handle the undo button. Currently, if the user hits the undo button the form will be reset. To preserve the custom front-end introduced information, you'd have to implement a buffer where you'd store the last x items in memory and populate the form as needed.

For convenience, here's the full javascript file. I also wrapped the custom javascript code in prodigymount event listener to make sure the app is fully loaded before attempting to modify the DOM.

document.addEventListener('prodigymount', () => {
  // Add initial rows
  const formContainer = document.getElementById('form-container');
  formContainer.appendChild(createRow());
  formContainer.appendChild(createRow());

  // Add event listener for the button
  const addButton = document.getElementById('add-row-btn');
  addButton.addEventListener('click', function() {
    formContainer.appendChild(createRow());
  });

  // Function to collect form data and update Prodigy task
  function updateProdigyTask() {
    // Collect all row containers
    const rowContainers = document.querySelectorAll('.row-container');
    
    // Create an array to store the form data
    const formData = [];
    
    // Iterate through each row and extract values
    rowContainers.forEach((row) => {
      // Use more specific selectors to get the correct inputs
      const termInput = row.querySelector('input[placeholder="Term"]');
      const justificationInput = row.querySelector('input[placeholder="Justification"]');
      const errorSelect = row.querySelector('select:first-of-type');
      const severitySelect = row.querySelector('select:last-of-type');
      
      // Create an object for this row
      const rowData = {
        term: termInput.value.trim(),
        justification: justificationInput.value.trim(),
        errorType: errorSelect.value,
        severity: severitySelect.value
      };
        
      // Only add non-empty rows (at least term or justification must be filled)
      if (rowData.term || rowData.justification) {
        formData.push(rowData);
      }
    });
    
    // Update the Prodigy task with the collected form data
    // this will add a new key `form_field` to Prodigy task saved in the DB
    prodigy.update({ "form_field": formData });
    console.log('Updated Prodigy task with form data:', formData);
  }
  
  // Add event listeners to the form container to detect changes in any input or select
  formContainer.addEventListener('input', function(event) {
    // Check if the event target is an input or select element
    if (event.target.tagName === 'INPUT' || event.target.tagName === 'SELECT') {
      // Update Prodigy task whenever any input or select value changes
      updateProdigyTask();
    }
  });
  
  // Also listen for change events (for selects)
  formContainer.addEventListener('change', function(event) {
    if (event.target.tagName === 'SELECT') {
      updateProdigyTask();
    }
  });

  document.addEventListener('prodigyanswer', () => {
    // Reset the form after saving
    resetForm();
  });
});

// Function to create a new row
function createRow() {
  const row = document.createElement('div');
  row.className = 'row-container';
  
  // Create elements explicitly instead of using innerHTML
  const termInput = document.createElement('input');
  termInput.placeholder = 'Term';
  termInput.className = 'term-input';
  
  const justificationInput = document.createElement('input');
  justificationInput.placeholder = 'Justification';
  justificationInput.className = 'justification-input';
  
  const errorSelect = document.createElement('select');
  errorSelect.className = 'error-select';
  const errorOptions = ['Error 1', 'Error 2', 'Error 3'];
  errorOptions.forEach(opt => {
    const option = document.createElement('option');
    option.textContent = opt;
    errorSelect.appendChild(option);
  });
  
  const severitySelect = document.createElement('select');
  severitySelect.className = 'severity-select';
  const severityOptions = ['Critical', 'Major', 'Minor'];
  severityOptions.forEach(opt => {
    const option = document.createElement('option');
    option.textContent = opt;
    severitySelect.appendChild(option);
  });
  
  // Append all elements to the row
  row.appendChild(termInput);
  row.appendChild(justificationInput);
  row.appendChild(errorSelect);
  row.appendChild(severitySelect);
  
  return row;
}

// Function to reset the form
function resetForm() {
  const formContainer = document.getElementById('form-container');
  
  // Clear all existing rows
  formContainer.innerHTML = '';
  
  // Add back two empty rows (default state)
  formContainer.appendChild(createRow());
  formContainer.appendChild(createRow());
  
  console.log('Form has been reset');
}

Let me know if you have any questions!